├── .gitignore ├── .sourcery ├── LinuxMain.stencil └── RuleFactory.stencil ├── .swiftlint.yml ├── .templates ├── Options.stencil ├── OptionsTests.stencil ├── Rule.stencil └── RuleTests.stencil ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Formula └── Projlint.rb ├── LICENSE ├── Logo.png ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Resources └── SwiftLint.stencil ├── Rules.md ├── Sources ├── ProjLint │ └── main.swift └── ProjLintKit │ ├── Commands │ └── LintCommand.swift │ ├── Globals │ ├── ConfigurationManager.swift │ ├── Extensions │ │ ├── FileManagerExtension.swift │ │ ├── PBXGroupExtension.swift │ │ ├── StringExtension.swift │ │ └── URLSessionExtension.swift │ ├── GlobalOptions.swift │ ├── Globals.swift │ ├── PrintLevel.swift │ └── RuleFactory.swift │ ├── Models │ ├── Configuration.swift │ ├── File.swift │ ├── FileViolation.swift │ ├── Rule.swift │ ├── RuleOptions.swift │ ├── Violation.swift │ └── ViolationLevel.swift │ └── Rules │ ├── FileContentRegexOptions.swift │ ├── FileContentRegexRule.swift │ ├── FileContentTemplateOptions.swift │ ├── FileContentTemplateRule.swift │ ├── FileExistenceOptions.swift │ ├── FileExistenceRule.swift │ ├── XcodeBuildPhasesOptions.swift │ ├── XcodeBuildPhasesRule.swift │ ├── XcodeProjectNavigatorOptions.swift │ └── XcodeProjectNavigatorRule.swift ├── Tests ├── LinuxMain.swift └── ProjLintKitTests │ ├── Globals │ ├── Faker.swift │ ├── Resource.swift │ └── ResourceTests.swift │ └── Rules │ ├── FileContentRegexOptionsTests.swift │ ├── FileContentRegexRuleTests.swift │ ├── FileContentTemplateOptionsTests.swift │ ├── FileContentTemplateRuleTests.swift │ ├── FileExistenceOptionsTests.swift │ ├── FileExistenceRuleTests.swift │ ├── XcodeBuildPhasesOptionsTests.swift │ ├── XcodeBuildPhasesRuleTests.swift │ ├── XcodeProjectNavigatorOptionsTests.swift │ └── XcodeProjectNavigatorRuleTests.swift └── beak.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots/**/*.png 68 | fastlane/test_output 69 | 70 | 71 | # SwiftPM 72 | .DS_Store 73 | /.build 74 | /Packages 75 | /*.xcodeproj 76 | 77 | # Testing 78 | .testResources -------------------------------------------------------------------------------- /.sourcery/LinuxMain.stencil: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKitTests 2 | import XCTest 3 | 4 | // swiftlint:disable line_length file_length 5 | 6 | {% for type in types.classes|based:"XCTestCase" %} 7 | extension {{ type.name }} { 8 | static var allTests: [(String, ({{ type.name }}) -> () throws -> Void)] = [ 9 | {% for method in type.methods where method.parameters.count == 0 and method.shortName|hasPrefix:"test" and method|!annotated:"skipTestOnLinux" %} ("{{ method.shortName }}", {{ method.shortName }}){% if not forloop.last %},{% endif %} 10 | {% endfor %}] 11 | } 12 | 13 | {% endfor %} 14 | XCTMain([ 15 | {% for type in types.classes|based:"XCTestCase" %} testCase({{ type.name }}.allTests){% if not forloop.last %},{% endif %} 16 | {% endfor %}]) 17 | -------------------------------------------------------------------------------- /.sourcery/RuleFactory.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum RuleFactory { 4 | static func makeRule(identifier: String, optionsDict: [String: Any]?, sharedVariables: [String: String]?) -> Rule { 5 | let optionsDict = sharedVariablesAppliedDict(optionsDict ?? [:], sharedVariables: sharedVariables ?? [:]) 6 | 7 | switch identifier { 8 | {% for type in types.all %} 9 | {% if type.name|hasSuffix:"Rule" %} 10 | case {{ type.name }}.identifier: 11 | return {{ type.name }}(optionsDict) 12 | 13 | {% endif %} 14 | {% endfor %} 15 | default: 16 | print("Rule with identifier \(identifier) unknown.", level: .error) 17 | exit(EX_USAGE) 18 | } 19 | } 20 | 21 | private static func sharedVariablesAppliedDict(_ dictionary: [String: Any], sharedVariables: [String: String]) -> [String: Any] { 22 | var newDict = [String: Any]() 23 | 24 | for (key, value) in dictionary { 25 | let newKey = sharedVariablesAppliedString(key, sharedVariables: sharedVariables) 26 | let newValue: Any = { 27 | if let stringValue = value as? String { 28 | return sharedVariablesAppliedString(stringValue, sharedVariables: sharedVariables) 29 | } else if let stringArrayValue = value as? [String] { 30 | return stringArrayValue.map { sharedVariablesAppliedString($0, sharedVariables: sharedVariables) } 31 | } else if let dictValue = value as? [String: Any] { 32 | return sharedVariablesAppliedDict(dictValue, sharedVariables: sharedVariables) 33 | } else { 34 | return value 35 | } 36 | }() 37 | 38 | newDict[newKey] = newValue 39 | } 40 | 41 | return newDict 42 | } 43 | 44 | private static func sharedVariablesAppliedString(_ string: String, sharedVariables: [String: String]) -> String { 45 | var newString = string 46 | 47 | for (placeholder, replacement) in sharedVariables { 48 | newString = newString.replacingOccurrences(of: "<:\(placeholder):>", with: replacement) 49 | } 50 | 51 | return newString 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Basic Configuration 2 | opt_in_rules: 3 | - array_init 4 | - attributes 5 | - closure_end_indentation 6 | - closure_spacing 7 | - conditional_returns_on_newline 8 | - contains_over_first_not_nil 9 | - convenience_type 10 | - empty_count 11 | - empty_string 12 | - empty_xctest_method 13 | - explicit_init 14 | - explicit_type_interface 15 | - extension_access_modifier 16 | - fallthrough 17 | - fatal_error_message 18 | - file_header 19 | - file_name 20 | - file_types_order 21 | - first_where 22 | - function_default_parameter_at_end 23 | - implicitly_unwrapped_optional 24 | - is_disjoint 25 | - joined_default_parameter 26 | - let_var_whitespace 27 | - literal_expression_end_indentation 28 | - lower_acl_than_parent 29 | - modifier_order 30 | - multiline_arguments 31 | - multiline_arguments_brackets 32 | - multiline_function_chains 33 | - multiline_literal_brackets 34 | - multiline_parameters 35 | - multiline_parameters_brackets 36 | - nimble_operator 37 | - no_extension_access_modifier 38 | - number_separator 39 | - object_literal 40 | - operator_usage_whitespace 41 | - overridden_super_call 42 | - override_in_extension 43 | - pattern_matching_keywords 44 | - private_action 45 | - private_outlet 46 | - prohibited_super_call 47 | - quick_discouraged_call 48 | - quick_discouraged_focused_test 49 | - quick_discouraged_pending_test 50 | - redundant_nil_coalescing 51 | - redundant_type_annotation 52 | - single_test_class 53 | - sorted_first_last 54 | - sorted_imports 55 | - switch_case_on_newline 56 | - trailing_closure 57 | - type_contents_order 58 | - unneeded_parentheses_in_closure_argument 59 | - untyped_error_in_catch 60 | - vertical_parameter_alignment_on_call 61 | - vertical_whitespace_between_cases 62 | - vertical_whitespace_closing_braces 63 | - vertical_whitespace_opening_braces 64 | - yoda_condition 65 | 66 | disabled_rules: 67 | - force_cast 68 | - superfluous_disable_command 69 | - todo 70 | - type_name 71 | 72 | included: 73 | - Sources 74 | - Tests 75 | 76 | excluded: 77 | - Tests/LinuxMain.swift 78 | 79 | # Rule Configurations 80 | conditional_returns_on_newline: 81 | if_only: true 82 | 83 | explicit_type_interface: 84 | allow_redundancy: true 85 | excluded: 86 | - local 87 | 88 | file_header: 89 | required_pattern: "" 90 | 91 | file_name: 92 | suffix_pattern: "Extensions?|\\+.*" 93 | 94 | file_types_order: 95 | order: 96 | - supporting_type 97 | - main_type 98 | - extension 99 | 100 | identifier_name: 101 | excluded: 102 | - id 103 | 104 | line_length: 160 105 | 106 | type_contents_order: 107 | order: 108 | - case 109 | - [type_alias, associated_type] 110 | - subtype 111 | - type_property 112 | - instance_property 113 | - ib_outlet 114 | - initializer 115 | - type_method 116 | - view_life_cycle_method 117 | - ib_action 118 | - other_method 119 | - subscript 120 | 121 | # Custom Rules 122 | custom_rules: 123 | closing_brace_whitespace: 124 | included: ".*.swift" 125 | regex: '(?:\n| {2,})\}\)? *\n *[^ \n\})\]s#""]' 126 | name: "Closing Brace Whitespace" 127 | message: "Empty line required after closing curly braces if code with same indentation follows." 128 | severity: warning 129 | closure_params_parantheses: 130 | included: ".*.swift" 131 | regex: '\{\s*\([^):]+\)\s*in' 132 | name: "Unnecessary Closure Params Parantheses" 133 | message: "Don't use parantheses around non-typed parameters in a closure." 134 | severity: warning 135 | comment_type_note: 136 | included: ".*.swift" 137 | regex: '// *(?:WORKAROUND|HACK|WARNING)[:\\s]' 138 | name: "Comment Type NOTE" 139 | message: "Use a '// NOTE:' comment instead." 140 | severity: warning 141 | comment_type_refactor: 142 | included: ".*.swift" 143 | regex: '// *(?:TODO|NOTE)[:\\s][^\n]*(?:refactor|REFACTOR|Refactor)' 144 | name: "Comment Type REFACTOR" 145 | message: "Use a '// REFACTOR:' comment instead." 146 | severity: warning 147 | comment_type_todo: 148 | included: ".*.swift" 149 | regex: '// *(?:BUG|MOCK|FIXME|RELEASE|TEST)[:\\s]' 150 | name: "Comment Type TODO" 151 | message: "Use a '// TODO:' comment instead." 152 | severity: warning 153 | empty_init_body: 154 | included: ".*.swift" 155 | regex: 'init\([^\{\n]*\) \{\s+\}' 156 | name: "Empty Init Body" 157 | message: "Don't use whitespace or newlines for the body of empty initializers – use `init() {}` instead." 158 | severity: warning 159 | empty_method: 160 | included: ".*.swift" 161 | regex: 'func [^\s\(]+\([^\{\n]*\) \{\s*\}' 162 | name: "Empty Method" 163 | message: "Don't keep empty methods in code without commenting inside why they are needed or a `// TODO: not yet implemented`." 164 | severity: warning 165 | empty_type: 166 | included: ".*.swift" 167 | regex: '(?:class|protocol|struct|enum) [^\{]+\{\s*\}' 168 | name: "Empty Type" 169 | message: "Don't keep empty types in code without commenting inside why they are needed or a `// TODO: not yet implemented`." 170 | severity: warning 171 | handyswift_delay: 172 | included: ".*.swift" 173 | regex: 'DispatchQueue\.\S+\.asyncAfter\(deadline:' 174 | name: "HandySwift Delay" 175 | message: "Use the `delay(by:qosClass:)` method of HandySwift instead of DispatchQueue for delayed code." 176 | severity: warning 177 | handyswift_delay_time_interval: 178 | included: ".*.swift" 179 | regex: 'delay\(by: ?\d' 180 | name: "HandySwift Delay TimeInterval" 181 | message: "Use one of the HandySwift TimeInterval extension methods like `.milliseconds(100)` instead." 182 | severity: warning 183 | handyswift_time_interval: 184 | included: ".*.swift" 185 | regex: ':\s*(?:Dispatch)?TimeInterval\s*=\s*(?:[1-9]|0\.0*[1-9])' 186 | name: "HandySwift TimeInterval" 187 | message: "Use one of the HandySwift (Dispatch)TimeInterval extension methods like `.milliseconds(100)` instead." 188 | severity: warning 189 | if_as_guard: 190 | included: ".*.swift" 191 | regex: '(?<=\n) *if [^\{]+\{\s*return\s*(?:nil){0,1}([^a-zA-z\n]*)\n*\s*\}(?! *else)' 192 | name: "If as Guard" 193 | message: "Don't use an if statement to just return – use guard for such cases instead." 194 | severity: warning 195 | late_force_unwrapping: 196 | included: ".*.swift" 197 | regex: '\(\S+\?\.\S+\)!' 198 | name: "Late Force Unwrapping" 199 | message: "Don't use ? first to force unwrap later – directly unwrap within the parantheses." 200 | severity: warning 201 | leveled_print: 202 | included: ".*.swift" 203 | regex: 'print\(\"[^\"]+\"\)' 204 | name: "Leveled Print" 205 | message: "Don't use print without specifying the print level." 206 | severity: warning 207 | multiple_closure_params: 208 | included: ".*.swift" 209 | regex: '\n *(?:[^\.\n=]+\.)+[^\(\s]+\([^\{\n]+\{[^\}\n]+\}\)\s*\{' 210 | name: "Multiple Closure Params" 211 | message: "Don't use multiple in-line closures – save one or more of them to variables instead." 212 | severity: warning 213 | none_case_enum: 214 | included: ".*.swift" 215 | regex: 'enum\s+[^\{]+\{(?:\s*\/\/[^\n]*)*(?:\s*case\s+[^\n]+)*\s*case\s+none[^\S]' 216 | name: "Non Case Enum" 217 | message: "Do not call enum cases `none` as you might run into problems with Optionals of this type." 218 | severity: warning 219 | quick_temporary_disabling: 220 | included: ".*.swift" 221 | regex: '\sxdescribe\(|\sxcontext\(|\sxit\(' 222 | name: "Quick Temporary Disabling" 223 | message: "Temporary disabled Quick examples or groups shouldn't be commited." 224 | severity: warning 225 | quick_temporary_focus: 226 | included: ".*.swift" 227 | regex: '\sfdescribe\(|\sfcontext\(|\sfit\(' 228 | name: "Quick Temporary Focus" 229 | message: "Temporary focused Quick examples or groups shouldn't be commited." 230 | severity: warning 231 | remove_where_for_negative_filtering: 232 | included: ".*.swift" 233 | regex: '\.filter *\{ *!\$0\.[^\}&|]+\}' 234 | name: "Remove Where for Negative Filtering" 235 | message: "Use `remove(where:)` instead of `filter(where not ...)` for performance." 236 | severity: warning 237 | self_conditional_binding: 238 | included: ".*.swift" 239 | regex: '\s+`?\w+`?(?\w+)(?:<[^\>]+>)? *\{.*static let `default`(?:: *\k)? *= *\k\(.*(?<=private) init\(' 258 | name: "Singleton Default Private Init" 259 | message: "Singletons with a `default` object (pseudo-singletons) should not declare init methods as private." 260 | severity: warning 261 | singleton_shared_final: 262 | included: ".*.swift" 263 | regex: '(?\w+)(?:<[^\>]+>)? *\{.*static let shared(?:: *\k)? *= *\k\(' 264 | name: "Singleton Shared Final" 265 | message: "Singletons with a single object (`shared`) should be marked as final." 266 | severity: warning 267 | singleton_shared_private_init: 268 | included: ".*.swift" 269 | regex: 'class +(?\w+)(?:<[^\>]+>)? *\{.*static let shared(?:: *\k)? *= *\k\(.*(?<= |\t|public|internal) init\(' 270 | name: "Singleton Shared Private Init" 271 | message: "Singletons with a single object (`shared`) should declare their init method(s) as private." 272 | severity: warning 273 | singleton_shared_single_object: 274 | included: ".*.swift" 275 | regex: 'class +(?\w+)(?:<[^\>]+>)? *\{.*(?:static let shared(?:: *\k)? *= *\k\(.*static let \w+(?:: *\k)? *= *\k\(|static let \w+(?:: *\k)? *= *\k\(.*static let shared(?:: *\k)? *= *\k\()' 276 | name: "Singleton Shared Single Object" 277 | message: "Singletons with a `shared` object (real Singletons) should not have other static let properties. Use `default` instead (if needed)." 278 | severity: warning 279 | switch_associated_value_style: 280 | included: ".*.swift" 281 | regex: 'case\s+[^\(][^\n]*(?:\(let |[^\)], let)' 282 | name: "Switch Associated Value Style" 283 | message: "Always put the `let` in front of case – even if only one associated value captured." 284 | severity: warning 285 | toggle_bool: 286 | included: ".*.swift" 287 | regex: '(?<=\n)[ \t]*(?\w+) *= *!\k(?=\s)' 288 | name: "Toggle Bool" 289 | message: "Use `toggle()` instead of toggling manually." 290 | severity: warning 291 | too_much_indentation: 292 | included: ".*.swift" 293 | regex: '\n {0}[^\s\/][^\n]*[^,|&]\n+ {5,}\S|\n {4}[^\s\/][^\n]*[^,|&]\n+ {9,}\S|\n {8}[^\s\/][^\n]*[^,|&]\n+ {13,}\S|\n {12}[^\s\/][^\n]*[^,|&]\n+ {17,}\S|\n {16}[^\s\/][^\n]*[^,|&]\n+ {21,}\S|\n {20}[^\s\/][^\n]*[^,|&]\n+ {25,}\S' 294 | name: "Too Much Indentation" 295 | message: "Don't indent code by more than 4 whitespaces." 296 | severity: warning 297 | too_much_unindentation: 298 | included: ".*.swift" 299 | regex: ' {28}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,23}[^\s\/]| {24}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,19}[^\s\/]| {20}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,15}[^\s\/]| {16}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,11}[^\s\/]| {12}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,7}[^\s\/]| {8}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,3}[^\s\/]' 300 | name: "Too Much Unindentation" 301 | message: "Don't unindent code by more than 4 whitespaces." 302 | severity: warning 303 | tuple_index: 304 | included: ".*.swift" 305 | regex: '(\$\d|\w*[^\d \(\[\{])\.\d' 306 | name: "Tuple Index" 307 | message: "Prevent unwraping tuples by their index – define a typealias with named components instead." 308 | severity: warning 309 | unnecessary_case_break: 310 | included: ".*.swift" 311 | regex: '(case |default)(?:[^\n\}]+\n){2,}\s*break *\n|\n *\n *break(?:\n *\n|\n *\})' 312 | name: "Unnecessary Case Break" 313 | message: "Don't use break in switch cases – Swift breaks by default." 314 | severity: warning 315 | unnecessary_nil_assignment: 316 | included: ".*.swift" 317 | regex: 'var \S+\s*:\s*[^\s]+\?\s*=\s*nil' 318 | name: "Unnecessary Nil Assignment" 319 | message: "Don't assign nil as a value when defining an optional type – it's nil by default." 320 | severity: warning 321 | vertical_whitespaces_around_mark: 322 | included: ".*.swift" 323 | regex: '\/\/\s*MARK:[^\n]*(\n\n)|(\n\n\n)[ \t]*\/\/\s*MARK:|[^\s{]\n[^\n\/]*\/\/\s*MARK:' 324 | name: "Vertical Whitespaces Around MARK:" 325 | message: "Include a single vertical whitespace (empty line) before and none after MARK: comments." 326 | severity: warning 327 | view_controller_variable_naming: 328 | included: ".*.swift" 329 | regex: '(?:let|var) +\w*(?:vc|VC|Vc|viewC|viewController|ViewController) *=' 330 | name: "View Controller Variable Naming" 331 | message: "Always name your view controller variables with the suffix `ViewCtrl`." 332 | severity: warning 333 | whitespace_around_range_operators: 334 | included: ".*.swift" 335 | regex: '\w\.\.[<\.]\w' 336 | name: "Whitespace around Range Operators" 337 | message: "A range operator should be surrounded by a single whitespace." 338 | severity: warning 339 | whitespace_comment_start: 340 | included: ".*.swift" 341 | regex: '[^:#\]\}\)][^:#\]\}\)]\/\/[^\s\/]' 342 | name: "Whitespace Comment Start" 343 | message: "A comment should always start with a whitespace." 344 | severity: warning 345 | # This rule is needed because, when passing a relative path to URL(fileURLWithPath:), it automatically creates it relative to currentDirectoryPath 346 | # Most of the times this is an undesired behavior, as the path may be relative to other directory different than the current one 347 | url_should_have_relative: 348 | included: ".*.swift" 349 | regex: 'fileURLWithPath(?!([^\n]*relativeTo))' 350 | message: "Url should specify its path relative to a base directory: use 'URL(fileURLWithPath: path, relativeTo: baseDirectory)'" 351 | severity: error 352 | prefer_url_over_string: 353 | included: ".*.swift" 354 | regex: '\.currentDirectoryPath' 355 | message: "Use 'currentDirectoryUrl' instead of 'currentDirectoryPath'" 356 | severity: warning -------------------------------------------------------------------------------- /.templates/Options.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | class {{ typePrefix }}Options: RuleOptions { 5 | // TODO: add options here 6 | 7 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 8 | // TODO: initialize options here 9 | 10 | super.init(optionsDict, rule: rule) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.templates/OptionsTests.stencil: -------------------------------------------------------------------------------- 1 | import HandySwift 2 | @testable import ProjLintKit 3 | import XCTest 4 | 5 | final class {{ typePrefix }}OptionsTests: XCTestCase { 6 | func testInitWithOptionName() { // TODO: set option name 7 | let valueType = FakerType.text // TODO: set value type for option 8 | let optionsDict = ["": Faker.first.data(ofType: valueType)] // TODO: set option name 9 | 10 | let options = {{ typePrefix }}Options(optionsDict, rule: {{ typePrefix }}Rule.self) 11 | 12 | // XCTAssert(options.<#optionName#> != nil) 13 | // XCTAssertEqual(options.<#optionName#>!.count, 5) // TODO: update assertion 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.templates/Rule.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | struct {{ typePrefix }}Rule: Rule { 5 | static let name: String = "{{ name }}" 6 | static let identifier: String = "{{ identifier }}" 7 | 8 | private let defaultViolationLevel: ViolationLevel = .warning 9 | private let options: {{ typePrefix }}Options 10 | 11 | init(_ optionsDict: [String: Any]) { 12 | options = {{ typePrefix }}Options(optionsDict, rule: type(of: self)) 13 | } 14 | 15 | func violations(in directory: URL) -> [Violation] { 16 | var violations = [Violation]() 17 | 18 | // TODO: find violations here 19 | 20 | return violations 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.templates/RuleTests.stencil: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | final class {{ typePrefix }}RuleTests: XCTestCase { 5 | // TODO: initialize resources here 6 | 7 | func testOptionName() { // TODO: update option name 8 | resourcesLoaded([]) { // TODO: add positive example resource to array 9 | let optionsDict = ["": ""] // TODO: set positive options of type [String: Any] 10 | let rule = {{ typePrefix }}Rule(optionsDict) 11 | 12 | let violations = rule.violations(in: Resource.baseUrl) 13 | XCTAssertEqual(violations.count, 0) 14 | } 15 | 16 | resourcesLoaded([]) { // TODO: add negative example resource to array 17 | let optionsDict = ["": ""] // TODO: set negative options of type [String: Any] 18 | let rule = {{ typePrefix }}Rule(optionsDict) 19 | 20 | let violations = rule.violations(in: Resource.baseUrl) 21 | XCTAssertEqual(violations.count, 1) // TODO: set violations count for example 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | ### Added 8 | - None. 9 | ### Changed 10 | - None. 11 | ### Deprecated 12 | - None. 13 | ### Removed 14 | - None. 15 | ### Fixed 16 | - None. 17 | ### Security 18 | - None. 19 | 20 | ## [0.3.0] - 2019-09-11 21 | ### Added 22 | - Make the rule `File Content Regex` print the offending lines. 23 | Issue: [#31](https://github.com/JamitLabs/ProjLint/issues/31) | PR: [#32](https://github.com/JamitLabs/ProjLint/pull/32) | Author: [Andrés Cecilia Luque](https://github.com/acecilia) 24 | - Added the `allowed_paths_regex` subrule under the file existance rule. Now it is possible to specify the allowed paths in a project by using multiple regexes. 25 | Issues: [#16](https://github.com/JamitLabs/ProjLint/issues/16), [#20](https://github.com/JamitLabs/ProjLint/issues/20) | PR: [#34](https://github.com/JamitLabs/ProjLint/pull/34) | Author: [Andrés Cecilia Luque](https://github.com/acecilia) 26 | ### Changed 27 | - Replaced `lint_fail_level` configuration option with `strict` command line argument. Specify `--strict` or `-s` if you want the tool to fail on warnings. 28 | ### Fixed 29 | - Updated project to use URLs instead of String paths. 30 | Issue: [#35](https://github.com/JamitLabs/ProjLint/issues/35) | PR: [#33](https://github.com/JamitLabs/ProjLint/pull/33) | Author: [Andrés Cecilia Luque](https://github.com/acecilia) 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at cihat.guenduez@jamitlabs.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Bug reports and pull requests are welcome on GitHub at https://github.com/JamitLabs/ProjLint. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 4 | 5 | ## Getting Started 6 | 7 | This section will tell you how you can get started contributing to ProjLint. 8 | 9 | ### Prerequisites 10 | 11 | Before you start developing, please make sure you have the following tools installed on your machine: 12 | 13 | - Xcode 10.1+ 14 | - [SwiftLint](https://github.com/realm/SwiftLint) 15 | - [Beak](https://github.com/yonaskolb/Beak) 16 | - [Sourcery](https://github.com/krzysztofzablocki/Sourcery) 17 | 18 | ### Useful Commands 19 | 20 | In order to generate the **Xcode project** to develop within, run this command: 21 | 22 | ``` 23 | swift package generate-xcodeproj 24 | ``` 25 | 26 | To check if all **tests** are passing correctly: 27 | 28 | ``` 29 | swift test 30 | ``` 31 | 32 | To check if the **linter** shows any warnings or errors: 33 | 34 | ``` 35 | swiftlint 36 | ``` 37 | 38 | Alternatively you can also add `swiftlint` as a build script to the target `ProjLintKit` so warnings & errors show off right within Xcode when building. (Recommended) 39 | 40 | To **create a new rule** with options and tests preconfigured: 41 | 42 | ``` 43 | beak run generateRule --identifier my_new_rule 44 | ``` 45 | 46 | This will add a `Rule` and an `Option` file to the `ProjLintKit` target as well as tests for both and integrate the rule in the `RuleFactory` so it is correctly identified by ProjLint. The generated files include `TODO` comments for the task to be completed in order for the rule to work. 47 | 48 | To **update the Linux tests** (required after adding/renaming/removing test methods): 49 | 50 | ``` 51 | beak run generateLinuxMain 52 | ``` 53 | 54 | This will make sure the Linux CI can also find and run all the tests. 55 | 56 | ### Development Tips 57 | 58 | #### Debugging with Xcode 59 | To run the ProjLint tool right from within Xcode for testing, remove the line 60 | 61 | ```swift 62 | cli.goAndExit() 63 | ``` 64 | 65 | from the file at path `Sources/ProjLint/main.swift` and replace it with: 66 | 67 | ```swift 68 | cli.debugGo(with: "projlint lint -v") 69 | ``` 70 | 71 | Now, when you choose the `ProjLint` scheme in Xcode and run the tool, you will see the command line output right within the Xcode console and can debug using breakpoints like you normally would. 72 | 73 | Beware though that the tool will run within the product build directory, which might look something like this: 74 | 75 | ``` 76 | /Users/YOU/Library/Developer/Xcode/DerivedData/ProjLint-aayvtbwcxecganalwqrvbfznkjke/Build/Products/Debug 77 | ``` 78 | 79 | You can print the exact directory of your Xcode by running: 80 | 81 | ```swift 82 | FileManager.default.currentDirectoryPath 83 | ``` 84 | 85 | To test a specific ProjLint configuration just create a `.projlint.yml` file within that directory and place your example project to check there, too. 86 | 87 | ### Commit Messages 88 | 89 | Please also try to follow the same syntax and semantic in your **commit messages** (see rationale [here](http://chris.beams.io/posts/git-commit/)). 90 | -------------------------------------------------------------------------------- /Formula/Projlint.rb: -------------------------------------------------------------------------------- 1 | class Projlint < Formula 2 | desc "Project Linter to enforce your non-code best practices" 3 | homepage "https://github.com/JamitLabs/ProjLint" 4 | url "https://github.com/JamitLabs/ProjLint.git", :tag => "0.3.0", :revision => "e2105687ed161e1e611be874f060f63ab14135ab" 5 | head "https://github.com/JamitLabs/ProjLint.git" 6 | 7 | depends_on :xcode => ["10.2", :build] 8 | 9 | def install 10 | system "make", "install", "prefix=#{prefix}" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jamit Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamitLabs/ProjLint/9c642dcc92ab0e4c12017d2cf7863724dc2f62e4/Logo.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | 3 | prefix ?= /usr/local 4 | bindir ?= $(prefix)/bin 5 | libdir ?= $(prefix)/lib 6 | srcdir = Sources 7 | 8 | REPODIR = $(shell pwd) 9 | BUILDDIR = $(REPODIR)/.build 10 | SOURCES = $(wildcard $(srcdir)/**/*.swift) 11 | 12 | .DEFAULT_GOAL = all 13 | 14 | .PHONY: all 15 | all: projlint 16 | 17 | projlint: $(SOURCES) 18 | @swift build \ 19 | -c release \ 20 | --disable-sandbox \ 21 | --build-path "$(BUILDDIR)" 22 | 23 | .PHONY: install 24 | install: projlint 25 | @install -d "$(bindir)" "$(libdir)" 26 | @install "$(BUILDDIR)/release/projlint" "$(bindir)" 27 | 28 | .PHONY: uninstall 29 | uninstall: 30 | @rm -rf "$(bindir)/projlint" 31 | 32 | .PHONY: clean 33 | distclean: 34 | @rm -f $(BUILDDIR)/release 35 | 36 | .PHONY: clean 37 | clean: distclean 38 | @rm -rf $(BUILDDIR) 39 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AEXML", 6 | "repositoryURL": "https://github.com/tadija/AEXML", 7 | "state": { 8 | "branch": null, 9 | "revision": "54bb8ea6fb693dd3f92a89e5fcc19e199fdeedd0", 10 | "version": "4.3.3" 11 | } 12 | }, 13 | { 14 | "package": "CLISpinner", 15 | "repositoryURL": "https://github.com/kiliankoe/CLISpinner.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "93ca0c508e2bfcfc65ef068f667b3f20c327158c", 19 | "version": "0.3.6" 20 | } 21 | }, 22 | { 23 | "package": "Difference", 24 | "repositoryURL": "https://github.com/Dschee/Difference.git", 25 | "state": { 26 | "branch": "master", 27 | "revision": "37018576e3f56c6c63ed9865a211bccefa8bd3e2", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "HandySwift", 33 | "repositoryURL": "https://github.com/Flinesoft/HandySwift.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "f736ec0ab264269cd4df91d6a685b4c78292cd76", 37 | "version": "2.8.0" 38 | } 39 | }, 40 | { 41 | "package": "Rainbow", 42 | "repositoryURL": "https://github.com/onevcat/Rainbow.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "797a68d0a642609424b08f11eb56974a54d5f6e2", 46 | "version": "3.1.4" 47 | } 48 | }, 49 | { 50 | "package": "Spectre", 51 | "repositoryURL": "https://github.com/kylef/Spectre.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "f14ff47f45642aa5703900980b014c2e9394b6e5", 55 | "version": "0.9.0" 56 | } 57 | }, 58 | { 59 | "package": "Stencil", 60 | "repositoryURL": "https://github.com/stencilproject/Stencil.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "0e9a78d6584e3812cd9c09494d5c7b483e8f533c", 64 | "version": "0.13.1" 65 | } 66 | }, 67 | { 68 | "package": "SwiftCLI", 69 | "repositoryURL": "https://github.com/jakeheis/SwiftCLI.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "5318c37d3cacc8780f50b87a8840a6774320ebdf", 73 | "version": "5.2.2" 74 | } 75 | }, 76 | { 77 | "package": "SwiftShell", 78 | "repositoryURL": "https://github.com/kareman/SwiftShell", 79 | "state": { 80 | "branch": null, 81 | "revision": "beebe43c986d89ea5359ac3adcb42dac94e5e08a", 82 | "version": "4.1.2" 83 | } 84 | }, 85 | { 86 | "package": "xcodeproj", 87 | "repositoryURL": "https://github.com/tuist/xcodeproj.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "3fe1bd763072c81050b867d34db56d11cb9085bb", 91 | "version": "6.6.0" 92 | } 93 | }, 94 | { 95 | "package": "Yams", 96 | "repositoryURL": "https://github.com/jpsim/Yams.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "26ab35f50ea891e8edefcc9d975db2f6b67e1d68", 100 | "version": "1.0.1" 101 | } 102 | } 103 | ] 104 | }, 105 | "version": 1 106 | } 107 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ProjLint", 7 | platforms: [ 8 | .macOS(.v10_11), 9 | ], 10 | products: [ 11 | .executable(name: "projlint", targets: ["ProjLint"]), 12 | .library(name: "ProjLintKit", targets: ["ProjLintKit"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/kiliankoe/CLISpinner.git", .upToNextMinor(from: "0.3.5")), 16 | .package(url: "https://github.com/Dschee/Difference.git", .branch("master")), 17 | .package(url: "https://github.com/Flinesoft/HandySwift.git", .upToNextMajor(from: "2.6.0")), 18 | .package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMajor(from: "3.1.4")), 19 | .package(url: "https://github.com/stencilproject/Stencil.git", .upToNextMajor(from: "0.11.0")), 20 | .package(url: "https://github.com/jakeheis/SwiftCLI.git", .upToNextMajor(from: "5.1.2")), 21 | .package(url: "https://github.com/tuist/xcodeproj.git", .upToNextMajor(from: "6.0.1")), 22 | .package(url: "https://github.com/jpsim/Yams.git", .upToNextMajor(from: "1.0.0")) 23 | ], 24 | targets: [ 25 | .target( 26 | name: "ProjLint", 27 | dependencies: ["ProjLintKit"] 28 | ), 29 | .target( 30 | name: "ProjLintKit", 31 | dependencies: [ 32 | "CLISpinner", 33 | "Difference", 34 | "HandySwift", 35 | "Rainbow", 36 | "Stencil", 37 | "SwiftCLI", 38 | "xcodeproj", 39 | "Yams" 40 | ] 41 | ), 42 | .testTarget( 43 | name: "ProjLintKitTests", 44 | dependencies: ["ProjLintKit", "HandySwift"] 45 | ) 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | Build Status 10 | 11 | 12 | Codebeat Badge 14 | 15 | 16 | Version: 0.3.0 18 | 19 | Swift: 5.0 21 | Platforms: macOS | Linux 23 | 24 | License: MIT 26 | 27 |

28 | 29 |

30 | Installation 31 | • Usage 32 | • Contributing 33 | • License 34 |

35 | 36 | # ProjLint 37 | 38 | Project Linter to enforce your non-code best practices. 39 | 40 | ## Requirements 41 | 42 | - Xcode 10.2+ and Swift 5.0+ 43 | - Xcode Command Line Tools (see [here](http://stackoverflow.com/a/9329325/3451975) for installation instructions) 44 | 45 | ## Installation 46 | 47 | ### Using [Homebrew](https://brew.sh): 48 | 49 | To **install** ProjLint the first time run these commands: 50 | 51 | ```bash 52 | brew tap JamitLabs/ProjLint https://github.com/JamitLabs/ProjLint.git 53 | brew install projlint 54 | ``` 55 | 56 | To **update** to the latest version run: 57 | 58 | ```bash 59 | brew upgrade projlint 60 | ``` 61 | 62 | ### Using [Mint](https://github.com/yonaskolb/Mint): 63 | 64 | To **install** the latest version of ProjLint simply run this command: 65 | 66 | ```bash 67 | $ mint install JamitLabs/ProjLint 68 | ``` 69 | 70 | ## Usage 71 | 72 | ProjLint provides the following sub commands: 73 | - **`lint`**: Lints the current directory and shows warnings and errors as console output. 74 | 75 | **Shared Flags:** 76 | - `--verbose`, `-v`: Prints out more detailed information about steps taken. 77 | 78 | **Lint-only Flags:** 79 | - `--xcode`, `-x`: Output are done in a format that is compatible with Xcode – for usage in Build Scripts. 80 | - `--timeout`, `-t`: Seconds to wait for network requests until skipped. 81 | - `--ignore-network-errors`, `-i`: Ignores network timeouts or missing network connection errors. 82 | - `--strict`, `-s`: Exit with non-zero status on warnings, too. (Only for errors by default.) 83 | 84 | NOTE: It is recommended to set the options `--timeout 2` and `--ignore-network-errors` if you plan to run `projlint lint` automatically on every build. Otherwise your build time might increase significantly on bad/missing internet connections. 85 | 86 | ### Configuration 87 | 88 | To configure the checks ProjLint does for you, you need to have a YAML configuration file named `.projlint.yml` in the current directory. In there, you have the following sections: 89 | 90 | - [`Default Options`](#default-options): Documented below, these options are applied to all rules unless they override them specifically. 91 | - [`Rules with Options`](#rules-with-options): The list of rules to check & correct when the appropriate tasks are run with ability to customize them. 92 | - [`Shared Variables`](#shared-variables): Define String variables to be replaced in rule options using structure `<:var_name:>`. 93 | 94 | In addition to the `.projlint.yml` file, you can also place an additional `.projlint-local.yml` file with the same possibilities as in the normal config file. This allows you to share the same `.projlint.yml` file amongst multiple projects and keep them in sync while adding project-specific rules via the `-local` config file. Note that defaults options and shared variables with the same keys in the `-local` file will override those from the normal file. 95 | 96 | #### Default Options 97 | 98 | The following default options are available: 99 | 100 | Option | Type | Required? | Description 101 | --- | --- | --- | --- 102 | `forced_violation_level` | `String` | no | One of `warning` or `error` – enforces the specified level on all violations. 103 | 104 | All default options can be overridden by specifying a different value within the rule options. Here's an example: 105 | 106 | ```yaml 107 | default_options: 108 | forced_violation_level: warning 109 | ``` 110 | 111 | #### Rules with Options 112 | 113 | A list of all currently available rules and their options can be found in the [Rules.md](https://github.com/JamitLabs/ProjLint/blob/stable/Rules.md) file. The structure of how rules can be configured looks like the following: 114 | 115 | ```yaml 116 | rules: 117 | - file_existence: # rule identifier 118 | forced_violation_level: warning # override default option 119 | paths: # rule option (note the additional indentation) 120 | - .swiftlint.yml 121 | - README.md 122 | - CONTRIBUTING.md 123 | - CHANGELOG.md 124 | - file_content_template: #rule identifier 125 | matching: # rule option 126 | .swiftlint.yml: 127 | template_url: "https://raw.githubusercontent.com/JamitLabs/ProjLintTemplates/master/iOS/SwiftLint.stencil" 128 | ``` 129 | 130 | #### Shared Variables 131 | 132 | A dictionary where you can define variables which can be used in strings anywhere amongst rule options. Say a variable named `project_name` was specified with the value `MyAmazingProject`, then all appearances of `<:project_name:>` in rule option strings will be replaced by `MyAmazingProject`. Here's what a config file using shared variables might look like: 133 | 134 | ```yaml 135 | shared_variables: 136 | project_name: MyAmazingProject 137 | 138 | rules: 139 | - file_existence: 140 | paths: 141 | - <:project_name:>.xcodeproj 142 | - <:project_name:>/Sources/AppDelegate.swift 143 | ``` 144 | 145 | ## Contributing 146 | 147 | See the file [CONTRIBUTING.md](https://github.com/JamitLabs/ProjLint/blob/stable/CONTRIBUTING.md). 148 | 149 | ## License 150 | This library is released under the [MIT License](http://opensource.org/licenses/MIT). See LICENSE for details. 151 | -------------------------------------------------------------------------------- /Resources/SwiftLint.stencil: -------------------------------------------------------------------------------- 1 | # Basic Configuration 2 | opt_in_rules:{% for rule in additionalRules %}\n- {{ rule }}{% endfor %} 3 | 4 | disabled_rules: 5 | - type_name 6 | 7 | included: 8 | - Sources 9 | - Tests 10 | 11 | # Rule Configurations 12 | identifier_name: 13 | excluded: 14 | - id 15 | 16 | line_length: {{ lineLength }} 17 | -------------------------------------------------------------------------------- /Rules.md: -------------------------------------------------------------------------------- 1 | # Rules 2 | This file contains a description and a list of options for every rule that ProjLint supports. The rules are sorted alphabetically by their name. 3 | 4 | ## Table of Rules 5 | 6 | The following is a table of all available rules. Just click an option to get to it's available options. 7 | 8 | Name | Identifier | Correctable? | Description 9 | --- | --- | --- | --- 10 | [File Content Regex](#file-content-regex) | `file_content_regex` | no | Specify files which must or must not include regex(es). 11 | [File Content Template](#file-content-template) | `file_content_template` | no | Specify files which must or must not match a file template. 12 | [File Existence](#file-existence) | `file_existence` | no | Specify files which must or must not exist. 13 | [Xcode Build Phases](#xcode-build-phases) | `xcode_build_phases` | no | Specify build phases that need to exist and have same content. 14 | [Xcode Project Navigator](#xcode-project-navigator) | `xcode_build_phases` | no | Specify how the project navigator should be structured. 15 | 16 | 17 | ## Rule Options 18 | 19 | ### File Content Regex 20 | 21 | Option | Type | Required? | Description 22 | --- | --- | --- | --- 23 | `matching` | `[String: Regex]` | no | Paths with regex to check – fails if given regex doesn't match. 24 | `matching_all` | `[String: [Regex]]` | no | Paths with regexes to check – fails if at least one regex doesn't match. 25 | `matching_any` | `[String: [Regex]]` | no | Paths with regexes to check – fails if all regexes don't match. 26 | `not_matching` | `[String: Regex]` | no | Paths with regex to check – fails if given regex matches. 27 | `not_matching_all` | `[String: [Regex]]` | no | Paths with regexes – fails if all regexes match. 28 | `not_matching_any` | `[String: [Regex]]` | no | Paths with regexes – fails if at least one regex matches. 29 | 30 |
31 | Example 32 | 33 | ```yaml 34 | rules: 35 | - file_content_regex: 36 | matching_all: 37 | Cartfile: 38 | - "#\\s*[^\\s]+" # Ensure dependencies are commented 39 | - HandySwift 40 | - SwiftyUserDefaults 41 | - SwiftyBeaver 42 | not_matching_all: 43 | Cartfile: # Moya already includes Alamofire, prevent redundancy 44 | - Alamofire 45 | - Moya 46 | ``` 47 | 48 |
49 | 50 | ### File Content Template 51 | 52 | Option | Type | Required? | Description 53 | --- | --- | --- | --- 54 | `matching` | `[String: ["template_path/template_url": String, "parameters": [String: Any]]` | no | Paths with template & parameters to check – fails if given template with parameters applied doesn't match the file contents. 55 | 56 |
57 | Example 58 | 59 | ```yaml 60 | rules: 61 | - file_content_template: 62 | matching: 63 | .swiftlint.yml: 64 | template_url: "https://raw.githubusercontent.com/JamitLabs/ProjLintTemplates/master/iOS/SwiftLint.stencil" 65 | parameters: 66 | additionalRules: 67 | - attributes 68 | - empty_count 69 | - sorted_imports 70 | lineLength: 160 71 | ``` 72 | 73 | Where the file `SwiftLint.stencil` could be a [Stencil](https://github.com/stencilproject/Stencil) template looking like this: 74 | 75 | ```stencil 76 | # Basic Configuration 77 | opt_in_rules: 78 | {% for rule in additionalRules %} 79 | - {{ rule }} 80 | {% endfor %} 81 | 82 | disabled_rules: 83 | - type_name 84 | 85 | included: 86 | - Sources 87 | - Tests 88 | 89 | # Rule Configurations 90 | identifier_name: 91 | excluded: 92 | - id 93 | 94 | line_length: {{ lineLength }} 95 | ``` 96 | 97 | Note that a `template_path` should be specified for local paths and a `template_url` should be specified if your file needs to be downloaded from the web. 98 | 99 |
100 | 101 | ### File Existence 102 | 103 | Option | Type | Required? | Description 104 | --- | --- | --- | --- 105 | `existing_paths` | `[String]` | no | Files that must exist. 106 | `non_existing_paths` | `[String]` | no | Files that must not exist. 107 | `allowed_paths_regex` | `[String]` | no | A list of regexes matching only allowed paths: files with a path that do not match the regex will trigger a violation. 108 | 109 |
110 | Example 111 | 112 | ```yaml 113 | rules: 114 | - file_existence: 115 | existing_paths: 116 | - .gitignore 117 | - README.md 118 | - Cartfile 119 | - Cartfile.private 120 | - Cartfile.resolved 121 | non_existing_paths: 122 | - Podfile 123 | - Podfile.lock 124 | allowed_paths_regex: 125 | - (Sources|Tests)/.+\.swift # Sources 126 | - (Resources|Formula)/.+ # Other necessary resources 127 | - \.(build|sourcery|git|templates)/.+ # Necessary files under hidden directories 128 | - ProjLint\.xcodeproj/.+ # Xcode project 129 | - '[^/]+' # Root files (needs quotation because the regex contains reserved yaml characters) 130 | ``` 131 | 132 |
133 | 134 | ### Xcode Build Phases 135 | 136 | Option | Type | Required? | Description 137 | --- | --- | --- | --- 138 | `project_path` | `String` | yes | The (relative) path to the `.xcodeproj` file. 139 | `target_name` | `String` | yes | The name of the targets whose build phases to be checked. 140 | `run_scripts` | `[String: String]` | yes | The build scripts to be checked – the key is the name, the value the script contents. 141 | 142 |
143 | Example 144 | 145 | ```yaml 146 | rules: 147 | - xcode_build_phases: 148 | project_path: AmazingApp.xcodeproj 149 | target_name: AmazingApp 150 | run_scripts: 151 | SwiftLint: | 152 | if which swiftlint > /dev/null; then 153 | swiftlint 154 | else 155 | echo "warning: SwiftLint not installed, download it from https://github.com/realm/SwiftLint" 156 | fi 157 | ``` 158 | 159 |
160 | 161 | ### Xcode Project Navigator 162 | 163 | Option | Type | Required? | Description 164 | --- | --- | --- | --- 165 | `project_path` | `String` | yes | The (relative) path to the `.xcodeproj` file. 166 | `sorted` | `[String]` | no | The group paths to check recursively for sorted entries (aware of inner_group_order). 167 | `inner_group_order` | `[String OR [String]]` | yes | The order of types within groups. Available types: `interfaces`, `code_files`, `assets`, `strings`, `folders`, `plists`, `entitlements`, `others`. 168 | `structure` | `[String: Any]` | yes | The structure of files and folders to check for their existence. 169 | 170 |
171 | Example 172 | 173 | ```yaml 174 | rules: 175 | - xcode_project_navigator: 176 | project_path: AmazingApp.xcodeproj 177 | sorted: 178 | - App/Sources 179 | - App/Generated 180 | - Tests/Sources 181 | - UITests/Sources/ 182 | inner_group_order: 183 | - assets 184 | - entitlements 185 | - plists 186 | - strings 187 | - others 188 | - [code_files, interfaces] 189 | - folders 190 | structure: 191 | - App: 192 | - Sources: 193 | - AppDelegate.swift 194 | - Globals: 195 | - Extensions 196 | - Resources: 197 | - Colors.xcassets 198 | - Images.xcassets 199 | - Localizable.strings 200 | - Fonts 201 | - SupportingFiles: 202 | - LaunchScreen.storyboard 203 | - Info.plist 204 | - Tests: 205 | - Sources 206 | - Resources 207 | - SupportingFiles: 208 | - Info.plist 209 | - UITests: 210 | - Sources 211 | - Resources 212 | - SupportingFiles: 213 | - Info.plist 214 | - Extensions 215 | - Frameworks 216 | - Products 217 | ``` 218 | 219 |
220 | -------------------------------------------------------------------------------- /Sources/ProjLint/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjLintKit 3 | import SwiftCLI 4 | 5 | // MARK: - CLI 6 | let cli = CLI(name: "projlint", version: "0.3.0", description: "Project Linter to lint & autocorrect your non-code best practices.") 7 | 8 | cli.commands = [LintCommand()] 9 | cli.globalOptions.append(contentsOf: GlobalOptions.all) 10 | cli.goAndExit() 11 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Commands/LintCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | 4 | public class LintCommand: Command { 5 | // MARK: - Command 6 | public let name: String = "lint" 7 | public let shortDescription: String = "Lints the current directory and shows warnings and errors as console output" 8 | 9 | public let xcode = Flag("-x", "--xcode", description: "Output are done in a format that is compatible with Xcode") 10 | public let timeout = Key("-t", "--timeout", description: "Seconds to wait for network requests until skipped") 11 | public let ignoreNetworkErrors = Flag("-i", "--ignore-network-errors", description: "Ignores network timeouts or missing network connection errors") 12 | public let strict = Flag("-s", "--strict", description: "Exit with non-zero status if any issue is found") 13 | 14 | // MARK: - Initializers 15 | public init() {} 16 | 17 | // MARK: - Instance Methods 18 | public func execute() throws { 19 | if xcode.value { 20 | Globals.outputFormatTarget = .xcode 21 | } 22 | 23 | if let timeout = timeout.value { 24 | Globals.timeout = timeout 25 | } 26 | 27 | if ignoreNetworkErrors.value { 28 | Globals.ignoreNetworkErrors = true 29 | } 30 | 31 | print("Started linting current directory...", level: .info) 32 | let configuration = ConfigurationManager.loadConfiguration() 33 | 34 | guard !configuration.rules.isEmpty else { 35 | print("No rules found in configuration file. Nothing to lint.", level: .warning) 36 | exit(EX_USAGE) 37 | } 38 | 39 | var errorViolationsCount = 0 40 | var warningViolationsCount = 0 41 | 42 | configuration.rules.forEach { rule in 43 | let violations = rule.violations(in: FileManager.default.currentDirectoryUrl) 44 | 45 | for violation in violations { 46 | violation.logViolation() 47 | 48 | if violation.level == .error { 49 | errorViolationsCount += 1 50 | } else { 51 | warningViolationsCount += 1 52 | } 53 | } 54 | } 55 | 56 | let allViolationsCount = warningViolationsCount + errorViolationsCount 57 | guard allViolationsCount <= 0 else { 58 | let printLevel: PrintLevel = errorViolationsCount > 0 ? .error : .warning 59 | print("Linting failed with \(errorViolationsCount) errors and \(warningViolationsCount) warnings in current directory.", level: printLevel) 60 | 61 | let shouldFail: Bool = { 62 | guard !strict.value else { return true } 63 | return errorViolationsCount > 0 64 | }() 65 | 66 | if shouldFail { 67 | exit(EXIT_FAILURE) 68 | } else { 69 | exit(EXIT_SUCCESS) 70 | } 71 | } 72 | 73 | print("Successfully linted current directory. No violations found.", level: .info) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/ConfigurationManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | import Yams 4 | 5 | private struct DefaultRule: Rule { 6 | static let name: String = "Default" 7 | static let identifier: String = "default" 8 | 9 | init(_ optionsDict: [String: Any]) { 10 | fatalError("This is not intended for direct usage – override in subclass instead.") 11 | } 12 | 13 | func violations(in directory: URL) -> [Violation] { 14 | fatalError("This is not intended for direct usage – override in subclass instead.") 15 | } 16 | } 17 | 18 | enum ConfigurationManager { 19 | static func loadConfiguration() -> Configuration { 20 | let currentDirUrl = FileManager.default.currentDirectoryUrl 21 | let configFileUrl = currentDirUrl.appendingPathComponent(".projlint.yml") 22 | 23 | guard let configContentString = try? String(contentsOf: configFileUrl, encoding: .utf8) else { 24 | print("Could not load contents of config file. Please make sure the file `.projlint.yml` exists.", level: .error) 25 | exit(EX_USAGE) 26 | } 27 | 28 | guard let configYaml = try? Yams.load(yaml: configContentString), let configDict = configYaml as? [String: Any] else { 29 | print("Could not load config file. Could not parse as YAML – please check if your file is valid YAML.", level: .error) 30 | exit(EX_USAGE) 31 | } 32 | 33 | var sharedVariables: [String: String] = { 34 | guard let sharedVariables = configDict["shared_variables"] as? [String: String] else { return [:] } 35 | return sharedVariables 36 | }() 37 | 38 | var defaultOptionsDict: [String: Any] = { 39 | guard let defaultOptionsDict = configDict["default_options"] as? [String: Any] else { return [:] } 40 | return defaultOptionsDict 41 | }() 42 | 43 | var ruleEntries: [Any] = configDict["rules"] as? [Any] ?? [] 44 | 45 | // override values using local config file if available 46 | let localConfigFileUrl = currentDirUrl.appendingPathComponent(".projlint-local.yml") 47 | 48 | if let localConfigContentString = try? String(contentsOf: localConfigFileUrl, encoding: .utf8) { 49 | guard let localConfigYaml = try? Yams.load(yaml: localConfigContentString), let localConfigDict = localConfigYaml as? [String: Any] else { 50 | print("Could not load local config file. Could not parse as YAML – please check if your file is valid YAML.", level: .error) 51 | exit(EX_USAGE) 52 | } 53 | 54 | let localSharedVariables: [String: String] = { 55 | guard let localSharedVariables = localConfigDict["shared_variables"] as? [String: String] else { return [:] } 56 | return localSharedVariables 57 | }() 58 | 59 | sharedVariables.merge(localSharedVariables) 60 | 61 | let localDefaultOptionsDict: [String: Any] = { 62 | guard let localDefaultOptionsDict = localConfigDict["default_options"] as? [String: Any] else { return [:] } 63 | return localDefaultOptionsDict 64 | }() 65 | 66 | defaultOptionsDict.merge(localDefaultOptionsDict) 67 | 68 | let localRuleEntries: [Any] = localConfigDict["rules"] as? [Any] ?? [] 69 | ruleEntries.append(contentsOf: localRuleEntries) 70 | } 71 | 72 | return Configuration( 73 | defaultOptions: RuleOptions(defaultOptionsDict, rule: DefaultRule.self), 74 | rules: rules(in: ruleEntries, sharedVariables: sharedVariables) 75 | ) 76 | } 77 | 78 | private static func rules(in ruleEntries: [Any], sharedVariables: [String: String]) -> [Rule] { 79 | var rules = [Rule]() 80 | 81 | for ruleEntry in ruleEntries { 82 | if let ruleIdentifier = ruleEntry as? String { 83 | let rule = RuleFactory.makeRule(identifier: ruleIdentifier, optionsDict: nil, sharedVariables: nil) 84 | rules.append(rule) 85 | } else if 86 | let ruleDict = ruleEntry as? [String: Any], 87 | let ruleIdentifier = ruleDict.keys.first, 88 | let optionsDict = ruleDict[ruleIdentifier] as? [String: Any] 89 | { 90 | let rule = RuleFactory.makeRule(identifier: ruleIdentifier, optionsDict: optionsDict, sharedVariables: sharedVariables) 91 | rules.append(rule) 92 | } else { 93 | print("Unexpected format in rule options.", level: .error) 94 | exit(EX_USAGE) 95 | } 96 | } 97 | 98 | return rules 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/Extensions/FileManagerExtension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | extension FileManager { 5 | var currentDirectoryUrl: URL { 6 | // Rationale: here we are sure that the string passed to the URL is absolute, so there is no need for a base directory 7 | // swiftlint:disable:next url_should_have_relative 8 | return URL(fileURLWithPath: currentDirectoryPath) 9 | } 10 | 11 | func removeContentsOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions = []) throws { 12 | guard fileExists(atPath: url.path) else { return } 13 | for suburl in try contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: mask) { 14 | try FileManager.default.removeItem(at: suburl) 15 | } 16 | } 17 | 18 | func createFile(at url: URL, withIntermediateDirectories: Bool, contents: Data?, attributes: [FileAttributeKey: Any]?) throws { 19 | let directoryUrl = url.deletingLastPathComponent() 20 | 21 | if withIntermediateDirectories && !FileManager.default.fileExists(atPath: directoryUrl.path) { 22 | try createDirectory(at: directoryUrl, withIntermediateDirectories: true, attributes: attributes) 23 | } 24 | 25 | createFile(atPath: url.path, contents: contents, attributes: attributes) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/Extensions/PBXGroupExtension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import xcodeproj 3 | 4 | extension PBXGroup { 5 | var groupChildren: [PBXGroup] { 6 | return children.filter { $0 is PBXGroup && !($0 is PBXVariantGroup) } as! [PBXGroup] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/Extensions/StringExtension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | extension String { 5 | func char(at offset: Int) -> Character? { 6 | let requestedIndex = index(startIndex, offsetBy: offset) 7 | guard requestedIndex < endIndex else { return nil } 8 | return self[requestedIndex] 9 | } 10 | 11 | func lineIndex(for characterIndex: Index) -> Int { 12 | return self[...characterIndex].components(separatedBy: .newlines).count 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/Extensions/URLSessionExtension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLSession { 4 | typealias Result = (Data?, URLResponse?, Error?) 5 | 6 | func syncDataTask(with url: URL) -> Result { 7 | var data: Data? 8 | var response: URLResponse? 9 | var error: Error? 10 | 11 | let semaphore = DispatchSemaphore(value: 0) 12 | 13 | let dataTask = self.dataTask(with: url) { 14 | data = $0 15 | response = $1 16 | error = $2 17 | 18 | semaphore.signal() 19 | } 20 | 21 | dataTask.resume() 22 | _ = semaphore.wait(timeout: .distantFuture) 23 | 24 | return (data, response, error) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/GlobalOptions.swift: -------------------------------------------------------------------------------- 1 | import SwiftCLI 2 | 3 | public enum GlobalOptions { 4 | static let verbose = Flag("-v", "--verbose", description: "Prints more detailed information about the executed command") 5 | 6 | public static var all: [Option] { 7 | return [verbose] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/Globals.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | enum Globals { 5 | static var outputFormatTarget: OutputFormatTarget = .human 6 | static var timeout: TimeInterval = .seconds(10) 7 | static var session: URLSession { 8 | let configuration = URLSessionConfiguration.default 9 | configuration.timeoutIntervalForRequest = timeout 10 | configuration.timeoutIntervalForResource = timeout 11 | return URLSession(configuration: configuration, delegate: nil, delegateQueue: nil) 12 | } 13 | static var ignoreNetworkErrors: Bool = false 14 | 15 | static let networkErrorCodes: [URLError.Code] = [.notConnectedToInternet, .timedOut] 16 | static let networkErrorFakeString: String = "#!-NETWORK_ERROR-!#" 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/PrintLevel.swift: -------------------------------------------------------------------------------- 1 | import CLISpinner 2 | import Foundation 3 | import Rainbow 4 | 5 | // swiftlint:disable leveled_print 6 | 7 | enum OutputFormatTarget { 8 | case human 9 | case xcode 10 | } 11 | 12 | enum PrintLevel { 13 | case verbose 14 | case info 15 | case warning 16 | case error 17 | 18 | var color: Color { 19 | switch self { 20 | case .verbose: 21 | return Color.lightCyan 22 | 23 | case .info: 24 | return Color.lightBlue 25 | 26 | case .warning: 27 | return Color.yellow 28 | 29 | case .error: 30 | return Color.red 31 | } 32 | } 33 | } 34 | 35 | func print(_ message: String, level: PrintLevel, file: String? = nil, line: Int? = nil) { 36 | switch Globals.outputFormatTarget { 37 | case .human: 38 | humanPrint(message, level: level, file: file, line: line) 39 | 40 | case .xcode: 41 | xcodePrint(message, level: level, file: file, line: line) 42 | } 43 | } 44 | 45 | private func humanPrint(_ message: String, level: PrintLevel, file: String? = nil, line: Int? = nil) { 46 | let location = locationInfo(file: file, line: line) 47 | let message = location != nil ? [location!, message].joined(separator: " ") : message 48 | 49 | switch level { 50 | case .verbose: 51 | if GlobalOptions.verbose.value { 52 | print("🗣 ", message.lightCyan) 53 | } 54 | 55 | case .info: 56 | print("ℹ️ ", message.lightBlue) 57 | 58 | case .warning: 59 | print("⚠️ ", message.yellow) 60 | 61 | case .error: 62 | print("❌ ", message.red) 63 | } 64 | } 65 | 66 | private func xcodePrint(_ message: String, level: PrintLevel, file: String? = nil, line: Int? = nil) { 67 | let location = locationInfo(file: file, line: line) 68 | 69 | switch level { 70 | case .verbose: 71 | if GlobalOptions.verbose.value { 72 | if let location = location { 73 | print(location, "verbose: ProjLint: ", message) 74 | } else { 75 | print("verbose: ProjLint: ", message) 76 | } 77 | } 78 | 79 | case .info: 80 | if let location = location { 81 | print(location, "info: ProjLint: ", message) 82 | } else { 83 | print("info: ProjLint: ", message) 84 | } 85 | 86 | case .warning: 87 | if let location = location { 88 | print(location, "warning: ProjLint: ", message) 89 | } else { 90 | print("warning: ProjLint: ", message) 91 | } 92 | 93 | case .error: 94 | if let location = location { 95 | print(location, "error: ProjLint: ", message) 96 | } else { 97 | print("error: ProjLint: ", message) 98 | } 99 | } 100 | } 101 | 102 | private func locationInfo(file: String?, line: Int?) -> String? { 103 | guard let file = file else { return nil } 104 | guard let line = line else { return "\(file): " } 105 | return "\(file):\(line): " 106 | } 107 | 108 | private let dispatchGroup = DispatchGroup() 109 | 110 | func performWithSpinner( 111 | _ message: String, 112 | level: PrintLevel = .info, 113 | pattern: CLISpinner.Pattern = .dots, 114 | _ body: @escaping (@escaping (() -> Void) -> Void) -> Void 115 | ) { 116 | let spinner = Spinner(pattern: pattern, text: message, color: level.color) 117 | spinner.start() 118 | spinner.unhideCursor() 119 | 120 | dispatchGroup.enter() 121 | body { completion in 122 | spinner.stopAndClear() 123 | completion() 124 | dispatchGroup.leave() 125 | } 126 | 127 | dispatchGroup.wait() 128 | } 129 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Globals/RuleFactory.swift: -------------------------------------------------------------------------------- 1 | // Generated using Sourcery 0.13.1 — https://github.com/krzysztofzablocki/Sourcery 2 | // DO NOT EDIT 3 | 4 | import Foundation 5 | 6 | enum RuleFactory { 7 | static func makeRule(identifier: String, optionsDict: [String: Any]?, sharedVariables: [String: String]?) -> Rule { 8 | let optionsDict = sharedVariablesAppliedDict(optionsDict ?? [:], sharedVariables: sharedVariables ?? [:]) 9 | 10 | switch identifier { 11 | case FileContentRegexRule.identifier: 12 | return FileContentRegexRule(optionsDict) 13 | 14 | case FileContentTemplateRule.identifier: 15 | return FileContentTemplateRule(optionsDict) 16 | 17 | case FileExistenceRule.identifier: 18 | return FileExistenceRule(optionsDict) 19 | 20 | case XcodeBuildPhasesRule.identifier: 21 | return XcodeBuildPhasesRule(optionsDict) 22 | 23 | case XcodeProjectNavigatorRule.identifier: 24 | return XcodeProjectNavigatorRule(optionsDict) 25 | 26 | default: 27 | print("Rule with identifier \(identifier) unknown.", level: .error) 28 | exit(EX_USAGE) 29 | } 30 | } 31 | 32 | private static func sharedVariablesAppliedDict(_ dictionary: [String: Any], sharedVariables: [String: String]) -> [String: Any] { 33 | var newDict = [String: Any]() 34 | 35 | for (key, value) in dictionary { 36 | let newKey = sharedVariablesAppliedString(key, sharedVariables: sharedVariables) 37 | let newValue: Any = { 38 | if let stringValue = value as? String { 39 | return sharedVariablesAppliedString(stringValue, sharedVariables: sharedVariables) 40 | } else if let stringArrayValue = value as? [String] { 41 | return stringArrayValue.map { sharedVariablesAppliedString($0, sharedVariables: sharedVariables) } 42 | } else if let dictValue = value as? [String: Any] { 43 | return sharedVariablesAppliedDict(dictValue, sharedVariables: sharedVariables) 44 | } else { 45 | return value 46 | } 47 | }() 48 | 49 | newDict[newKey] = newValue 50 | } 51 | 52 | return newDict 53 | } 54 | 55 | private static func sharedVariablesAppliedString(_ string: String, sharedVariables: [String: String]) -> String { 56 | var newString = string 57 | 58 | for (placeholder, replacement) in sharedVariables { 59 | newString = newString.replacingOccurrences(of: "<:\(placeholder):>", with: replacement) 60 | } 61 | 62 | return newString 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | struct Configuration { 5 | let defaultOptions: RuleOptions 6 | let rules: [Rule] 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/File.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class File { 4 | let url: URL 5 | 6 | private var cachedContents: String? 7 | 8 | var contents: String { 9 | return cachedContents ?? loadContents() 10 | } 11 | 12 | init(at url: URL) { 13 | self.url = url 14 | } 15 | 16 | private func loadContents() -> String { 17 | let contents: String = { 18 | if url.isFileURL { 19 | guard let contents = try? String(contentsOf: url, encoding: .utf8) else { 20 | print("Could not load contents of file '\(url)'.", level: .error) 21 | exit(EXIT_FAILURE) 22 | } 23 | 24 | return contents 25 | } else { 26 | let (dataOptional, _, errorOptional) = Globals.session.syncDataTask(with: url) 27 | 28 | if let error = errorOptional as? URLError, Globals.networkErrorCodes.contains(error.code) { 29 | return Globals.networkErrorFakeString 30 | } 31 | 32 | guard let data = dataOptional, let contents = String(data: data, encoding: .utf8) else { 33 | print("Could not load contents of file '\(url)'. Error: \(String(describing: errorOptional))", level: .error) 34 | exit(EXIT_FAILURE) 35 | } 36 | 37 | return contents 38 | } 39 | }() 40 | 41 | self.cachedContents = contents 42 | return contents 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/FileViolation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A violation that additionally has a path & optional line property which is logged alongside the message. 4 | class FileViolation: Violation { 5 | let url: URL 6 | let line: Int? 7 | 8 | init(rule: Rule, message: String, level: ViolationLevel, url: URL, line: Int? = nil) { 9 | self.url = url 10 | self.line = line 11 | 12 | super.init(rule: rule, message: message, level: level) 13 | } 14 | 15 | override func logViolation() { 16 | // In order for Xcode to show the error properly, the path to the file should be absolute 17 | let file = url.path 18 | print("\(type(of: rule).name) Violation – \(message)", level: level.printLevel, file: file, line: line) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/Rule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | import SwiftCLI 4 | 5 | protocol Rule { 6 | static var name: String { get } 7 | static var identifier: String { get } 8 | 9 | init(_ optionsDict: [String: Any]) 10 | func violations(in directory: URL) -> [Violation] 11 | } 12 | 13 | extension Rule { 14 | @available(OSX 10.12, *) 15 | func printDiffSummary(fileName: String, found: String, expected: String, printLevel: PrintLevel) { 16 | let tmpDirUrl = FileManager.default.temporaryDirectory.appendingPathComponent(".projlint") 17 | let foundTmpFileUrl = tmpDirUrl.appendingPathComponent("\(fileName).found") 18 | let expectedTmpFileUrl = tmpDirUrl.appendingPathComponent("\(fileName).expected") 19 | 20 | let foundTmpFileData = found.data(using: .utf8) 21 | let expectedTmpFileData = expected.data(using: .utf8) 22 | 23 | do { 24 | try FileManager.default.createFile(at: foundTmpFileUrl, withIntermediateDirectories: true, contents: foundTmpFileData, attributes: [:]) 25 | try FileManager.default.createFile(at: expectedTmpFileUrl, withIntermediateDirectories: true, contents: expectedTmpFileData, attributes: [:]) 26 | 27 | let diffOutput = try capture(bash: "git diff \(foundTmpFileUrl.path) \(expectedTmpFileUrl.path) || true").stdout 28 | print(diffOutput, level: printLevel) 29 | 30 | try FileManager.default.removeContentsOfDirectory(at: tmpDirUrl) 31 | } catch { 32 | print("Ignored an error: \(error)", level: .verbose) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/RuleOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | // swiftlint:disable type_contents_order 5 | 6 | class RuleOptions { 7 | /// Specifies when the lint command should fail. 8 | let forcedViolationLevel: ViolationLevel? 9 | 10 | init(_ optionsDict: [String: Any], rule: Rule.Type) { 11 | forcedViolationLevel = RuleOptions.optionalViolationLevel(forOption: "forced_violation_level", in: optionsDict, rule: rule) 12 | } 13 | 14 | /// Returns the corrected violation level considering the option `forced_violation_level` in case it is set. 15 | func violationLevel(defaultTo defaultLevel: ViolationLevel) -> ViolationLevel { 16 | guard let forcedViolationLevel = forcedViolationLevel else { return defaultLevel } 17 | return forcedViolationLevel 18 | } 19 | 20 | // Bool 21 | static func optionalBool(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> Bool? { 22 | return bool(forOption: optionName, in: optionsDict, required: false, rule: rule) 23 | } 24 | 25 | static func requiredBool(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> Bool { 26 | return bool(forOption: optionName, in: optionsDict, required: true, rule: rule)! 27 | } 28 | 29 | private static func bool(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> Bool? { 30 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 31 | 32 | guard let bool = optionsDict[optionName] as? Bool else { 33 | let message = """ 34 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 35 | Expected value to be of type `Bool`. Value: \(String(describing: optionsDict[optionName])) 36 | """ 37 | print(message, level: .error) 38 | exit(EX_USAGE) 39 | } 40 | 41 | return bool 42 | } 43 | 44 | // String 45 | static func optionalString(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> String? { 46 | return string(forOption: optionName, in: optionsDict, required: false, rule: rule) 47 | } 48 | 49 | static func requiredString(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> String { 50 | return string(forOption: optionName, in: optionsDict, required: true, rule: rule)! 51 | } 52 | 53 | private static func string(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> String? { 54 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 55 | 56 | guard let string = optionsDict[optionName] as? String else { 57 | let message = """ 58 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 59 | Expected value to be of type `String`. Value: \(String(describing: optionsDict[optionName])) 60 | """ 61 | print(message, level: .error) 62 | exit(EX_USAGE) 63 | } 64 | 65 | return string 66 | } 67 | 68 | // Regex 69 | static func optionalRegex(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> Regex? { 70 | return regex(forOption: optionName, in: optionsDict, required: false, rule: rule) 71 | } 72 | 73 | static func requiredRegex(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> Regex { 74 | return regex(forOption: optionName, in: optionsDict, required: true, rule: rule)! 75 | } 76 | 77 | private static func regex(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> Regex? { 78 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 79 | 80 | guard let string = optionsDict[optionName] as? String else { 81 | let message = """ 82 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 83 | Expected value to be of type `String`. Value: \(String(describing: optionsDict[optionName])) 84 | """ 85 | print(message, level: .error) 86 | exit(EX_USAGE) 87 | } 88 | 89 | guard let regex = try? Regex(string) else { 90 | print("The `\(optionName)` entry `\(string)` for rule \(rule.identifier) is not a valid Regex.", level: .error) 91 | exit(EX_USAGE) 92 | } 93 | 94 | return regex 95 | } 96 | 97 | // String Array 98 | static func optionalStringArray(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String]? { 99 | return stringArray(forOption: optionName, in: optionsDict, required: false, rule: rule) 100 | } 101 | 102 | static func requiredStringArray(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String] { 103 | return stringArray(forOption: optionName, in: optionsDict, required: true, rule: rule)! 104 | } 105 | 106 | private static func stringArray(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [String]? { 107 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 108 | 109 | guard let stringArray = optionsDict[optionName] as? [String] else { 110 | let message = """ 111 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 112 | Expected value to be of type `[String]`. Value: \(String(describing: optionsDict[optionName])) 113 | """ 114 | print(message, level: .error) 115 | exit(EX_USAGE) 116 | } 117 | 118 | return stringArray 119 | } 120 | 121 | // Regex Array 122 | static func optionalRegexArray(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [Regex]? { 123 | return regexArray(forOption: optionName, in: optionsDict, required: false, rule: rule) 124 | } 125 | 126 | static func requiredRegexArray(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [Regex] { 127 | return regexArray(forOption: optionName, in: optionsDict, required: true, rule: rule)! 128 | } 129 | 130 | private static func regexArray(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [Regex]? { 131 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 132 | 133 | let stringArray = requiredStringArray(forOption: optionName, in: optionsDict, rule: rule) 134 | return stringArray.map { pathString in 135 | guard let pathRegex = try? Regex(pathString) else { 136 | print("The `\(optionName)` entry `\(pathString)` for rule \(rule.identifier) is not a valid Regex.", level: .error) 137 | exit(EX_USAGE) 138 | } 139 | 140 | return pathRegex 141 | } 142 | } 143 | 144 | // Paths to Strings 145 | static func optionalPathsToStrings(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: String]? { 146 | return pathsToStrings(forOption: optionName, in: optionsDict, required: false, rule: rule) 147 | } 148 | 149 | static func requiredPathsToStrings(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: String] { 150 | return pathsToStrings(forOption: optionName, in: optionsDict, required: true, rule: rule)! 151 | } 152 | 153 | private static func pathsToStrings(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [String: String]? { 154 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 155 | 156 | guard let stringToStringDict = optionsDict[optionName] as? [String: String] else { 157 | let message = """ 158 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 159 | Expected value to be of type `[String: String]`. Value: \(String(describing: optionsDict[optionName])) 160 | """ 161 | print(message, level: .error) 162 | exit(EX_USAGE) 163 | } 164 | 165 | return stringToStringDict 166 | } 167 | 168 | // Paths to Regexes 169 | static func optionalPathsToRegexes(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: Regex]? { 170 | return pathsToRegexes(forOption: optionName, in: optionsDict, required: false, rule: rule) 171 | } 172 | 173 | static func requiredPathsToRegexes(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: Regex] { 174 | return pathsToRegexes(forOption: optionName, in: optionsDict, required: true, rule: rule)! 175 | } 176 | 177 | private static func pathsToRegexes(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [String: Regex]? { 178 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 179 | 180 | let stringToStringDict = requiredPathsToStrings(forOption: optionName, in: optionsDict, rule: rule) 181 | return stringToStringDict.mapValues { regexString in 182 | guard let regex = try? Regex(regexString) else { 183 | print("The `\(optionName)` entry `\(regexString)` for rule \(rule.identifier) is not a valid Regex.", level: .error) 184 | exit(EX_USAGE) 185 | } 186 | 187 | return regex 188 | } 189 | } 190 | 191 | // Paths to Regex Arrays 192 | static func optionalPathsToRegexArrays(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: [Regex]]? { 193 | return pathsToRegexArrays(forOption: optionName, in: optionsDict, required: false, rule: rule) 194 | } 195 | 196 | static func requiredPathsToRegexArrays(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: [Regex]] { 197 | return pathsToRegexArrays(forOption: optionName, in: optionsDict, required: true, rule: rule)! 198 | } 199 | 200 | private static func pathsToRegexArrays(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [String: [Regex]]? { 201 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 202 | 203 | guard let stringToAnyDict = optionsDict[optionName] as? [String: Any] else { 204 | let message = """ 205 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 206 | Expected value to be of type `[String: Any]`. Value: \(String(describing: optionsDict[optionName])) 207 | """ 208 | print(message, level: .error) 209 | exit(EX_USAGE) 210 | } 211 | 212 | var pathRegexes = [String: [Regex]]() 213 | stringToAnyDict.keys.forEach { path in 214 | pathRegexes[path] = requiredRegexArray(forOption: path, in: stringToAnyDict, rule: rule) 215 | } 216 | 217 | return pathRegexes 218 | } 219 | 220 | // Paths to URLs 221 | static func optionalPathsToURLs(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: URL]? { 222 | return pathsToURLs(forOption: optionName, in: optionsDict, required: false, rule: rule) 223 | } 224 | 225 | static func requiredPathsToURLs(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [String: URL] { 226 | return pathsToURLs(forOption: optionName, in: optionsDict, required: true, rule: rule)! 227 | } 228 | 229 | private static func pathsToURLs(forOption optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> [String: URL]? { 230 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 231 | 232 | let stringToStringDict = requiredPathsToStrings(forOption: optionName, in: optionsDict, rule: rule) 233 | return stringToStringDict.mapValues { path in 234 | guard let url = URL(string: path) else { 235 | print("The `\(optionName)` entry `\(path)` for rule \(rule.identifier) is not a valid URl.", level: .error) 236 | exit(EX_USAGE) 237 | } 238 | 239 | return url 240 | } 241 | } 242 | 243 | // Violation Level 244 | static func optionalViolationLevel(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> ViolationLevel? { 245 | return violationLevel(forOption: optionName, in: optionsDict, required: false, rule: rule) 246 | } 247 | 248 | static func requiredViolationLevel(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> ViolationLevel { 249 | return violationLevel(forOption: optionName, in: optionsDict, required: true, rule: rule)! 250 | } 251 | 252 | private static func violationLevel( 253 | forOption optionName: String, 254 | in optionsDict: [String: Any], 255 | required: Bool, 256 | rule: Rule.Type 257 | ) -> ViolationLevel? { 258 | guard optionExists(optionName, in: optionsDict, required: required, rule: rule) else { return nil } 259 | 260 | guard let violationLevelString = optionsDict[optionName] as? String else { 261 | let message = """ 262 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 263 | Expected value to be of type `String`. Value: \(String(describing: optionsDict[optionName])) 264 | """ 265 | print(message, level: .error) 266 | exit(EX_USAGE) 267 | } 268 | 269 | guard let violationLevel = ViolationLevel(rawValue: violationLevelString) else { 270 | print("The `\(optionName)` entry `\(violationLevelString)` for rule \(rule.identifier) has an invalid value.", level: .error) 271 | exit(EX_USAGE) 272 | } 273 | 274 | return violationLevel 275 | } 276 | 277 | // MARK: - Helpers 278 | static func optionExists(_ optionName: String, in optionsDict: [String: Any], required: Bool, rule: Rule.Type) -> Bool { 279 | guard optionsDict.keys.contains(optionName) else { 280 | guard !required else { 281 | print("Could not find required option `\(optionName)` for rule \(rule.identifier) in config file.", level: .error) 282 | exit(EX_USAGE) 283 | } 284 | 285 | return false 286 | } 287 | 288 | return true 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/Violation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A violation of a rule with a message and violation level to be logged in a report. 4 | class Violation { 5 | let rule: Rule 6 | let message: String 7 | let level: ViolationLevel 8 | 9 | init(rule: Rule, message: String, level: ViolationLevel) { 10 | self.rule = rule 11 | self.message = message 12 | self.level = level 13 | } 14 | 15 | func logViolation() { 16 | print("\(type(of: rule).name) Violation – \(message)", level: level.printLevel, file: nil, line: nil) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Models/ViolationLevel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ViolationLevel: String { 4 | case warning 5 | case error 6 | 7 | var printLevel: PrintLevel { 8 | switch self { 9 | case .error: 10 | return .error 11 | 12 | case .warning: 13 | return .warning 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileContentRegexOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | class FileContentRegexOptions: RuleOptions { 5 | let matchingPathRegex: [String: Regex]? 6 | let matchingAllPathRegexes: [String: [Regex]]? 7 | let matchingAnyPathRegexes: [String: [Regex]]? 8 | let notMatchingPathRegex: [String: Regex]? 9 | let notMatchingAllPathRegexes: [String: [Regex]]? 10 | let notMatchingAnyPathRegexes: [String: [Regex]]? 11 | 12 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 13 | let matchingPathRegex = RuleOptions.optionalPathsToRegexes(forOption: "matching", in: optionsDict, rule: rule) 14 | let matchingAllPathRegexes = RuleOptions.optionalPathsToRegexArrays(forOption: "matching_all", in: optionsDict, rule: rule) 15 | let matchingAnyPathRegexes = RuleOptions.optionalPathsToRegexArrays(forOption: "matching_any", in: optionsDict, rule: rule) 16 | let notMatchingPathRegex = RuleOptions.optionalPathsToRegexes(forOption: "not_matching", in: optionsDict, rule: rule) 17 | let notMatchingAllPathRegexes = RuleOptions.optionalPathsToRegexArrays(forOption: "not_matching_all", in: optionsDict, rule: rule) 18 | let notMatchingAnyPathRegexes = RuleOptions.optionalPathsToRegexArrays(forOption: "not_matching_any", in: optionsDict, rule: rule) 19 | 20 | guard 21 | matchingPathRegex != nil || 22 | matchingAllPathRegexes != nil || 23 | matchingAnyPathRegexes != nil || 24 | notMatchingPathRegex != nil || 25 | notMatchingAllPathRegexes != nil || 26 | notMatchingAnyPathRegexes != nil 27 | else { 28 | print("Rule \(rule.identifier) must have at least one option specified.", level: .error) 29 | exit(EX_USAGE) 30 | } 31 | 32 | self.matchingPathRegex = matchingPathRegex 33 | self.matchingAllPathRegexes = matchingAllPathRegexes 34 | self.matchingAnyPathRegexes = matchingAnyPathRegexes 35 | self.notMatchingPathRegex = notMatchingPathRegex 36 | self.notMatchingAllPathRegexes = notMatchingAllPathRegexes 37 | self.notMatchingAnyPathRegexes = notMatchingAnyPathRegexes 38 | 39 | super.init(optionsDict, rule: rule) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileContentRegexRule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | struct FileContentRegexRule: Rule { 5 | static let name: String = "File Content Regex" 6 | static let identifier: String = "file_content_regex" 7 | 8 | private let defaultViolationLevel: ViolationLevel = .warning 9 | private let options: FileContentRegexOptions 10 | 11 | init(_ optionsDict: [String: Any]) { 12 | options = FileContentRegexOptions(optionsDict, rule: type(of: self)) 13 | } 14 | 15 | func violations(in directory: URL) -> [Violation] { // swiftlint:disable:this cyclomatic_complexity function_body_length 16 | var violations = [Violation]() 17 | 18 | if let matchingRegex = options.matchingPathRegex { 19 | for (path, regex) in matchingRegex { 20 | let url = URL(fileURLWithPath: path, relativeTo: directory) 21 | let file = File(at: url) 22 | 23 | if !regex.matches(file.contents) { 24 | violations.append( 25 | FileViolation( 26 | rule: self, 27 | message: "Content didn't match regex '\(regex)' where it should.", 28 | level: options.violationLevel(defaultTo: defaultViolationLevel), 29 | url: url 30 | ) 31 | ) 32 | } 33 | } 34 | } 35 | 36 | if let matchingAllPathRegexes = options.matchingAllPathRegexes { 37 | for (path, regexes) in matchingAllPathRegexes { 38 | let url = URL(fileURLWithPath: path, relativeTo: directory) 39 | let file = File(at: url) 40 | 41 | for regex in regexes { 42 | if !regex.matches(file.contents) { 43 | violations.append( 44 | FileViolation( 45 | rule: self, 46 | message: "Content didn't match regex '\(regex)' where it should.", 47 | level: options.violationLevel(defaultTo: defaultViolationLevel), 48 | url: url 49 | ) 50 | ) 51 | } 52 | } 53 | } 54 | } 55 | 56 | if let matchingAnyPathRegexes = options.matchingAnyPathRegexes { 57 | for (path, regexes) in matchingAnyPathRegexes { 58 | let url = URL(fileURLWithPath: path, relativeTo: directory) 59 | let file = File(at: url) 60 | if regexes.first(where: { $0.matches(file.contents) }) == nil { 61 | violations.append( 62 | FileViolation( 63 | rule: self, 64 | message: "Content didn't match any of the regexes: '\(regexes)'.", 65 | level: options.violationLevel(defaultTo: defaultViolationLevel), 66 | url: url 67 | ) 68 | ) 69 | } 70 | } 71 | } 72 | 73 | if let notMatchingRegex = options.notMatchingPathRegex { 74 | for (path, regex) in notMatchingRegex { 75 | let url = URL(fileURLWithPath: path, relativeTo: directory) 76 | let file = File(at: url) 77 | 78 | regex.matches(in: file.contents).forEach { 79 | let violation = FileViolation( 80 | rule: self, 81 | message: "Content matched regex '\(regex)' where it shouldn't.", 82 | level: options.violationLevel(defaultTo: defaultViolationLevel), 83 | url: url, 84 | line: file.contents.lineIndex(for: $0.range.lowerBound) 85 | ) 86 | violations.append(violation) 87 | } 88 | } 89 | } 90 | 91 | if let notMatchingAllPathRegexes = options.notMatchingAllPathRegexes { 92 | for (path, regexes) in notMatchingAllPathRegexes { 93 | let url = URL(fileURLWithPath: path, relativeTo: directory) 94 | let file = File(at: url) 95 | 96 | var notMatchingAllViolations: [FileViolation] = [] 97 | var allRegexMatched = true 98 | for regex in regexes { 99 | let matches = regex.matches(in: file.contents) 100 | if matches.isEmpty { 101 | allRegexMatched = false 102 | break 103 | } 104 | 105 | matches.forEach { 106 | let violation = FileViolation( 107 | rule: self, 108 | message: "Not matching all. Content matched regex '\(regex)' where it shouldn't.", 109 | level: options.violationLevel(defaultTo: defaultViolationLevel), 110 | url: url, 111 | line: file.contents.lineIndex(for: $0.range.lowerBound) 112 | ) 113 | notMatchingAllViolations.append(violation) 114 | } 115 | } 116 | 117 | if allRegexMatched { 118 | violations.append(contentsOf: notMatchingAllViolations) 119 | } 120 | } 121 | } 122 | 123 | if let notMatchingAnyPathRegexes = options.notMatchingAnyPathRegexes { 124 | for (path, regexes) in notMatchingAnyPathRegexes { 125 | let url = URL(fileURLWithPath: path, relativeTo: directory) 126 | let file = File(at: url) 127 | 128 | for regex in regexes { 129 | regex.matches(in: file.contents).forEach { 130 | let violation = FileViolation( 131 | rule: self, 132 | message: "Content matched regex '\(regex)' where it shouldn't.", 133 | level: options.violationLevel(defaultTo: defaultViolationLevel), 134 | url: url, 135 | line: file.contents.lineIndex(for: $0.range.lowerBound) 136 | ) 137 | violations.append(violation) 138 | } 139 | } 140 | } 141 | } 142 | 143 | return violations 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileContentTemplateOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | class FileContentTemplateOptions: RuleOptions { 5 | enum TemplateOrigin { 6 | case url(URL) 7 | case file(String) 8 | } 9 | 10 | struct TemplateWithParameters { 11 | let origin: TemplateOrigin 12 | let parameters: [String: Any] 13 | 14 | func originUrl(base: URL) -> URL { 15 | switch origin { 16 | case let .file(path): 17 | return URL(fileURLWithPath: path, relativeTo: base) 18 | 19 | case let .url(url): 20 | return url 21 | } 22 | } 23 | } 24 | 25 | let matchingPathTemplate: [String: TemplateWithParameters] 26 | 27 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 28 | self.matchingPathTemplate = FileContentTemplateOptions.pathTemplate(forOption: "matching", in: optionsDict) 29 | 30 | super.init(optionsDict, rule: rule) 31 | } 32 | 33 | private static func pathTemplate(forOption optionName: String, in optionsDict: [String: Any]) -> [String: TemplateWithParameters] { 34 | guard RuleOptions.optionExists(optionName, in: optionsDict, required: true, rule: FileContentTemplateRule.self) else { return [:] } 35 | guard let matchingDict = optionsDict[optionName] as? [String: Any] else { return [:] } 36 | 37 | return matchingDict.mapValues { value in 38 | guard let templateDict = value as? [String: Any], let parameters = templateDict["parameters"] as? [String: Any] else { 39 | print("Could not read template and parameters in config file.", level: .error) 40 | exit(EX_USAGE) 41 | } 42 | 43 | if let templatePath = templateDict["template_path"] as? String { 44 | return TemplateWithParameters(origin: .file(templatePath), parameters: parameters) 45 | } 46 | 47 | if let templateUrlString = templateDict["template_url"] as? String { 48 | guard let templateUrl = URL(string: templateUrlString) else { 49 | print("Could make a URL from String '\(templateUrlString)'.", level: .error) 50 | exit(EX_USAGE) 51 | } 52 | 53 | return TemplateWithParameters(origin: .url(templateUrl), parameters: parameters) 54 | } 55 | 56 | print("No template was specified – use `template_path` or `template_url` options to specify one.", level: .error) 57 | exit(EX_USAGE) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileContentTemplateRule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | import Stencil 4 | 5 | struct FileContentTemplateRule: Rule { 6 | static let name: String = "File Content Template" 7 | static let identifier: String = "file_content_template" 8 | 9 | private let defaultViolationLevel: ViolationLevel = .warning 10 | private let options: FileContentTemplateOptions 11 | 12 | init(_ optionsDict: [String: Any]) { 13 | options = FileContentTemplateOptions(optionsDict, rule: type(of: self)) 14 | } 15 | 16 | func violations(in directory: URL) -> [Violation] { 17 | var violations = [Violation]() 18 | 19 | for (path, templateWithParams) in options.matchingPathTemplate { 20 | let url = URL(fileURLWithPath: path, relativeTo: directory) 21 | let file = File(at: url) 22 | let templateFile = File(at: templateWithParams.originUrl(base: directory)) 23 | 24 | guard templateFile.contents != Globals.networkErrorFakeString else { 25 | if Globals.ignoreNetworkErrors { 26 | print("Skipped rule \(FileContentRegexRule.identifier) for file '\(url.path)'. Request resulted in a network error.", level: .info) 27 | continue 28 | } 29 | 30 | print("Could not load contents of file '\(templateFile.url)' – the request resultes in a network error.", level: .error) 31 | exit(EXIT_FAILURE) 32 | } 33 | 34 | let template = Template(templateString: templateFile.contents) 35 | guard let expectedFileContents = try? template.render(templateWithParams.parameters) else { 36 | print("Could not render template at path '\(templateFile.url)'.", level: .error) 37 | exit(EXIT_FAILURE) 38 | } 39 | 40 | if file.contents != expectedFileContents { 41 | if #available(OSX 10.12, *) { 42 | printDiffSummary( 43 | fileName: url.lastPathComponent, 44 | found: file.contents, 45 | expected: expectedFileContents, 46 | printLevel: defaultViolationLevel.printLevel 47 | ) 48 | } 49 | 50 | violations.append( 51 | FileViolation( 52 | rule: self, 53 | message: "Contents of file differ from expected contents.", 54 | level: defaultViolationLevel, 55 | url: url 56 | ) 57 | ) 58 | } 59 | } 60 | 61 | return violations 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileExistenceOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class FileExistenceOptions: RuleOptions { 4 | let existingPaths: [String]? 5 | let nonExistingPaths: [String]? 6 | let allowedPathsRegex: [String]? 7 | 8 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 9 | let existingPaths = RuleOptions.optionalStringArray(forOption: "existing_paths", in: optionsDict, rule: rule) 10 | let nonExistingPaths = RuleOptions.optionalStringArray(forOption: "non_existing_paths", in: optionsDict, rule: rule) 11 | let allowedPathsRegex = RuleOptions.optionalStringArray(forOption: "allowed_paths_regex", in: optionsDict, rule: rule) 12 | 13 | let options = [existingPaths, nonExistingPaths, allowedPathsRegex] 14 | guard options.contains(where: { $0 != nil }) else { 15 | print("Rule \(rule.identifier) must have at least one option specified.", level: .error) 16 | exit(EX_USAGE) 17 | } 18 | 19 | self.existingPaths = existingPaths 20 | self.nonExistingPaths = nonExistingPaths 21 | self.allowedPathsRegex = allowedPathsRegex 22 | 23 | super.init(optionsDict, rule: rule) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/FileExistenceRule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | struct FileExistenceRule: Rule { 5 | static let name: String = "File Existence" 6 | static let identifier: String = "file_existence" 7 | 8 | private let defaultViolationLevel: ViolationLevel = .warning 9 | private let options: FileExistenceOptions 10 | 11 | init(_ optionsDict: [String: Any]) { 12 | options = FileExistenceOptions(optionsDict, rule: type(of: self)) 13 | } 14 | 15 | func violations(in directory: URL) -> [Violation] { 16 | var violations = [Violation]() 17 | 18 | if let existingPaths = options.existingPaths { 19 | for path in existingPaths { 20 | let url = URL(fileURLWithPath: path, relativeTo: directory) 21 | if !FileManager.default.fileExists(atPath: url.path) { 22 | violations.append( 23 | FileViolation( 24 | rule: self, 25 | message: "Expected file to exist but didn't.", 26 | level: options.violationLevel(defaultTo: defaultViolationLevel), 27 | url: url 28 | ) 29 | ) 30 | } 31 | } 32 | } 33 | 34 | if let nonExistingPaths = options.nonExistingPaths { 35 | for path in nonExistingPaths { 36 | let url = URL(fileURLWithPath: path, relativeTo: directory) 37 | if FileManager.default.fileExists(atPath: url.path) { 38 | violations.append( 39 | FileViolation( 40 | rule: self, 41 | message: "Expected file not to exist but existed.", 42 | level: options.violationLevel(defaultTo: defaultViolationLevel), 43 | url: url 44 | ) 45 | ) 46 | } 47 | } 48 | } 49 | 50 | if let allowedPathsRegex = options.allowedPathsRegex { 51 | let allowedPathsViolations = violationsForAllowedPaths(allowedPathsRegex, in: directory) 52 | violations.append(contentsOf: allowedPathsViolations) 53 | } 54 | 55 | return violations 56 | } 57 | 58 | private func violationsForAllowedPaths(_ allowedPathsRegex: [String], in directory: URL) -> [Violation] { 59 | var violations: [Violation] = [] 60 | 61 | // Start by getting an array of all files under the directory. 62 | // After, remove all files that are allowed, until ending up with the list of notAllowedFiles 63 | var notAllowedFiles = recursivelyGetFiles(at: directory) 64 | 65 | // Do not check for paths that are already linted by previous projlint rules 66 | let existingPaths = options.existingPaths?.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? [] 67 | let nonExistingPaths = options.nonExistingPaths?.map { URL(fileURLWithPath: $0, relativeTo: directory) } ?? [] 68 | let pathsAlreadyLinted = existingPaths + nonExistingPaths 69 | notAllowedFiles.removeAll { existingFile in 70 | pathsAlreadyLinted.contains { $0.path == existingFile.path } 71 | } 72 | 73 | for allowedPathPattern in allowedPathsRegex { 74 | guard let allowedPathRegex = try? Regex("^\(allowedPathPattern)$") else { 75 | let violation = Violation( 76 | rule: self, 77 | message: "The following regex is not valid: '\(allowedPathPattern)'", 78 | level: .error 79 | ) 80 | violations.append(violation) 81 | break 82 | } 83 | 84 | notAllowedFiles.removeAll { allowedPathRegex.matches($0.relativePath) } 85 | } 86 | 87 | notAllowedFiles.forEach { 88 | let violation = FileViolation( 89 | rule: self, 90 | message: "File exists, but it mustn't.", 91 | level: options.violationLevel(defaultTo: defaultViolationLevel), 92 | url: $0 93 | ) 94 | violations.append(violation) 95 | } 96 | 97 | return violations 98 | } 99 | 100 | private func recursivelyGetFiles(at currentUrl: URL) -> [URL] { 101 | var files: [URL] = [] 102 | 103 | let resourceKeys: [URLResourceKey] = [.creationDateKey, .isRegularFileKey] 104 | let enumerator = FileManager.default.enumerator( 105 | at: currentUrl, 106 | includingPropertiesForKeys: resourceKeys 107 | )! 108 | 109 | for case let fileUrl as URL in enumerator { 110 | // Rationale: force-try is ok. This can never fail, as the resourceKeys passed here are also passed to the enumerator 111 | // swiftlint:disable:next force_try 112 | let resourceValues = try! fileUrl.resourceValues(forKeys: Set(resourceKeys)) 113 | // Force-unwrap is ok: this can never fail, as the isRegularFileKey resource key is passed previously to the enumerator 114 | if resourceValues.isRegularFile! { 115 | let url = URL(fileURLWithPath: fileUrl.path.replacingOccurrences(of: "\(currentUrl.path)/", with: ""), relativeTo: currentUrl) 116 | files.append(url) 117 | } 118 | } 119 | 120 | return files 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/XcodeBuildPhasesOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | class XcodeBuildPhasesOptions: RuleOptions { 5 | let projectPath: String 6 | let targetName: String 7 | let runScripts: [String: String] 8 | 9 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 10 | projectPath = RuleOptions.requiredString(forOption: "project_path", in: optionsDict, rule: rule) 11 | targetName = RuleOptions.requiredString(forOption: "target_name", in: optionsDict, rule: rule) 12 | runScripts = RuleOptions.requiredPathsToStrings(forOption: "run_scripts", in: optionsDict, rule: rule) 13 | 14 | super.init(optionsDict, rule: rule) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/XcodeBuildPhasesRule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | import xcodeproj 4 | 5 | struct XcodeBuildPhasesRule: Rule { 6 | static let name: String = "Xcode Build Phases" 7 | static let identifier: String = "xcode_build_phases" 8 | 9 | private let defaultViolationLevel: ViolationLevel = .warning 10 | private let options: XcodeBuildPhasesOptions 11 | 12 | init(_ optionsDict: [String: Any]) { 13 | options = XcodeBuildPhasesOptions(optionsDict, rule: type(of: self)) 14 | } 15 | 16 | func violations(in directory: URL) -> [Violation] { 17 | var violations = [Violation]() 18 | 19 | let projectUrl = URL(fileURLWithPath: options.projectPath, relativeTo: directory) 20 | guard let xcodeProj = try? XcodeProj(pathString: projectUrl.path) else { 21 | print("Could not read project file at path '\(projectUrl.path)'.", level: .error) 22 | exit(EXIT_FAILURE) 23 | } 24 | 25 | guard let target = xcodeProj.pbxproj.targets(named: options.targetName).first else { 26 | print("Target with name '\(options.targetName)' could not be found in project \(projectUrl.path).", level: .error) 27 | exit(EXIT_FAILURE) 28 | } 29 | 30 | let targetRunScripts = target.buildPhases.filter { $0.type() == BuildPhase.runScript } as! [PBXShellScriptBuildPhase] 31 | 32 | for (name, expectedScript) in options.runScripts { 33 | guard let runScript = targetRunScripts.first(where: { $0.name == name }) else { 34 | violations.append( 35 | FileViolation( 36 | rule: self, 37 | message: "Run script with name '\(name)' could not be found for target \(options.targetName).", 38 | level: defaultViolationLevel, 39 | url: projectUrl 40 | ) 41 | ) 42 | continue 43 | } 44 | 45 | guard let foundScript = runScript.shellScript else { 46 | violations.append( 47 | FileViolation( 48 | rule: self, 49 | message: "Run script with name '\(name)' in target \(options.targetName) does not have a shell script.", 50 | level: defaultViolationLevel, 51 | url: projectUrl 52 | ) 53 | ) 54 | continue 55 | } 56 | 57 | if foundScript != expectedScript { 58 | if #available(OSX 10.12, *) { 59 | printDiffSummary(fileName: name, found: foundScript, expected: expectedScript, printLevel: defaultViolationLevel.printLevel) 60 | } 61 | 62 | violations.append( 63 | FileViolation( 64 | rule: self, 65 | message: "Run script with name '\(name)' in target \(options.targetName) did not match expected contents.", 66 | level: defaultViolationLevel, 67 | url: projectUrl 68 | ) 69 | ) 70 | } 71 | } 72 | 73 | return violations 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/XcodeProjectNavigatorOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | class XcodeProjectNavigatorOptions: RuleOptions { 5 | enum GroupType: String { 6 | case interface = "interfaces" 7 | case codeFile = "code_files" 8 | case assets = "assets" 9 | case strings = "strings" 10 | case folder = "folders" 11 | case plist = "plists" 12 | case entitlement = "entitlements" 13 | case other = "others" 14 | } 15 | 16 | enum TreeNode { 17 | case leaf(String) 18 | case subtree(group: String, nodes: [TreeNode]) 19 | } 20 | 21 | let projectPath: String 22 | let sorted: [String]? 23 | let innerGroupOrder: [[GroupType]] 24 | let structure: [TreeNode] 25 | 26 | override init(_ optionsDict: [String: Any], rule: Rule.Type) { 27 | projectPath = RuleOptions.requiredString(forOption: "project_path", in: optionsDict, rule: rule) 28 | sorted = RuleOptions.optionalStringArray(forOption: "sorted", in: optionsDict, rule: rule) 29 | innerGroupOrder = XcodeProjectNavigatorOptions.orderedGroupTypes(forOption: "inner_group_order", in: optionsDict, rule: rule) 30 | structure = XcodeProjectNavigatorOptions.orderedStructure(forOption: "structure", in: optionsDict, rule: rule) 31 | 32 | super.init(optionsDict, rule: rule) 33 | } 34 | 35 | private static func orderedGroupTypes(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [[GroupType]] { 36 | guard RuleOptions.optionExists(optionName, in: optionsDict, required: true, rule: rule) else { 37 | let message = """ 38 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 39 | """ 40 | print(message, level: .error) 41 | exit(EX_USAGE) 42 | } 43 | 44 | guard let anyArray = optionsDict[optionName] as? [Any] else { 45 | let message = """ 46 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 47 | Expected value to be of type `[Any]`. Value: \(String(describing: optionsDict[optionName])) 48 | """ 49 | print(message, level: .error) 50 | exit(EX_USAGE) 51 | } 52 | 53 | let orderedStrings: [[String]] = anyArray.map { any in 54 | if let string = any as? String { 55 | return [string] 56 | } 57 | 58 | if let stringArray = any as? [String] { 59 | return stringArray 60 | } 61 | 62 | let message = """ 63 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 64 | Expected value to be of type `[String]` or `String`. Value: \(String(describing: any)) 65 | """ 66 | print(message, level: .error) 67 | exit(EX_USAGE) 68 | } 69 | 70 | return orderedStrings.map { strings in 71 | return strings.map { string in 72 | guard let groupType = GroupType(rawValue: string) else { 73 | print("Found invalid group order type '\(string)' for option '\(optionName)' for rule \(rule.identifier).", level: .error) 74 | exit(EX_USAGE) 75 | } 76 | 77 | return groupType 78 | } 79 | } 80 | } 81 | 82 | private static func orderedStructure(forOption optionName: String, in optionsDict: [String: Any], rule: Rule.Type) -> [TreeNode] { 83 | guard RuleOptions.optionExists(optionName, in: optionsDict, required: true, rule: rule) else { 84 | print("Could not read option `\(optionName)` for rule \(rule.identifier) from config file.", level: .error) 85 | exit(EX_USAGE) 86 | } 87 | 88 | guard let anyArray = optionsDict[optionName] as? [Any] else { 89 | let message = """ 90 | Could not read option `\(optionName)` for rule \(rule.identifier) from config file. 91 | Expected value to be of type `[Any]`. Value: \(String(describing: optionsDict[optionName])) 92 | """ 93 | print(message, level: .error) 94 | exit(EX_USAGE) 95 | } 96 | 97 | return treeNodes(from: anyArray) 98 | } 99 | 100 | private static func treeNodes(from array: [Any]) -> [TreeNode] { 101 | return array.map { node in 102 | switch node { 103 | case let dict as [String: [Any]]: 104 | let group = dict.keys.first! 105 | let array = dict[group]! 106 | return TreeNode.subtree(group: group, nodes: treeNodes(from: array)) 107 | 108 | case let string as String: 109 | return TreeNode.leaf(string) 110 | 111 | default: 112 | print("Structure is invalid. Error at: \(node)", level: .error) 113 | exit(EX_USAGE) 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/ProjLintKit/Rules/XcodeProjectNavigatorRule.swift: -------------------------------------------------------------------------------- 1 | import Difference 2 | import Foundation 3 | import HandySwift 4 | import xcodeproj 5 | 6 | struct XcodeProjectNavigatorRule: Rule { 7 | static let orderExceptionGroups: [String] = ["RootFiles"] 8 | 9 | static let name: String = "Xcode Project Navigator" 10 | static let identifier: String = "xcode_project_navigator" 11 | 12 | private let defaultViolationLevel: ViolationLevel = .warning 13 | private let options: XcodeProjectNavigatorOptions 14 | 15 | init(_ optionsDict: [String: Any]) { 16 | options = XcodeProjectNavigatorOptions(optionsDict, rule: type(of: self)) 17 | } 18 | 19 | func violations(in directory: URL) -> [Violation] { 20 | var violations = [Violation]() 21 | 22 | let projectUrl = URL(fileURLWithPath: options.projectPath, relativeTo: directory) 23 | guard let xcodeProj = try? XcodeProj(pathString: projectUrl.path) else { 24 | print("Could not read project file at path '\(projectUrl.path)'.", level: .error) 25 | exit(EXIT_FAILURE) 26 | } 27 | 28 | // find structure violations 29 | violations += self.violations(for: options.structure, in: xcodeProj.pbxproj, parentPathComponents: [], projectUrl: projectUrl) 30 | 31 | guard let rootGroup = try? xcodeProj.pbxproj.rootGroup()! else { 32 | print("Could not read root group for file at path '\(projectUrl.path)'.", level: .error) 33 | exit(EXIT_FAILURE) 34 | } 35 | 36 | // find inner group order violations 37 | violations += self.orderViolations(forChildrenIn: rootGroup, pbxproj: xcodeProj.pbxproj, parentPathComponents: [], projectUrl: projectUrl) 38 | 39 | // find sorted violations 40 | if let sortedPaths = options.sorted { 41 | for sortedPath in sortedPaths { 42 | // swiftlint:disable:next remove_where_for_negative_filtering 43 | let parentPathComponents = sortedPath.components(separatedBy: "/").filter { !$0.isBlank } 44 | violations += self.sortedViolations(pbxproj: xcodeProj.pbxproj, parentPathComponents: parentPathComponents, projectUrl: projectUrl) 45 | } 46 | } 47 | 48 | return violations 49 | } 50 | 51 | private func sortedViolations(pbxproj: PBXProj, parentPathComponents: [String], projectUrl: URL) -> [Violation] { 52 | var violations = [Violation]() 53 | var currentGroup: PBXGroup = try! pbxproj.rootGroup()! // swiftlint:disable:this force_try 54 | 55 | for pathComponent in parentPathComponents { 56 | let groupChildren = currentGroup.groupChildren 57 | guard let newGroup = groupChildren.first(where: { $0.path == pathComponent || $0.name == pathComponent }) else { 58 | let path = parentPathComponents.joined(separator: "/") 59 | print("Could not find group at path '\(path)' for sort validation in project '\(projectUrl.path)'.", level: .error) 60 | exit(EXIT_FAILURE) 61 | } 62 | 63 | currentGroup = newGroup 64 | } 65 | 66 | let children = currentGroup.children 67 | 68 | for expectedGroupTypes in options.innerGroupOrder { 69 | let childrenOfGroupTypes = children.filter { expectedGroupTypes.contains(groupType(for: $0)) } 70 | let childrenNames = childrenOfGroupTypes.map { name(for: $0) } 71 | let sortedChildrenNames = childrenNames.sorted() 72 | 73 | if sortedChildrenNames != childrenNames { 74 | let expected = expectedGroupTypes.map { $0.rawValue }.joined(separator: ",") 75 | let parentPath = parentPathComponents.joined(separator: "/") 76 | 77 | let difference = diff(sortedChildrenNames, childrenNames) 78 | 79 | let message = """ 80 | Entries of type(s) '\(expected)' in group '\(parentPath)' are not sorted by name. 81 | Found:\n\(childrenNames) 82 | Expected:\n\(sortedChildrenNames) 83 | Difference:\n\(difference.joined()) 84 | """ 85 | violations.append( 86 | FileViolation( 87 | rule: self, 88 | message: message, 89 | level: defaultViolationLevel, 90 | url: projectUrl 91 | ) 92 | ) 93 | } 94 | } 95 | 96 | for group in currentGroup.groupChildren { 97 | violations += self.sortedViolations(pbxproj: pbxproj, parentPathComponents: parentPathComponents + [name(for: group)], projectUrl: projectUrl) 98 | } 99 | 100 | return violations 101 | } 102 | 103 | private func orderViolations(forChildrenIn group: PBXGroup, pbxproj: PBXProj, parentPathComponents: [String], projectUrl: URL) -> [Violation] { 104 | guard group.name == nil || !XcodeProjectNavigatorRule.orderExceptionGroups.contains(group.name!) else { return [] } 105 | 106 | var violations = [Violation]() 107 | let children = group.children 108 | 109 | var lastMatchingIndex = -1 110 | for expectedGroupTypes in options.innerGroupOrder { 111 | var potentialViolatingIndexes = [Int]() 112 | 113 | let startIndex = lastMatchingIndex + 1 114 | for index in (startIndex ..< children.count) { 115 | let groupType = self.groupType(for: children[index]) 116 | if expectedGroupTypes.contains(groupType) { 117 | lastMatchingIndex = index 118 | } else { 119 | potentialViolatingIndexes.append(index) 120 | } 121 | } 122 | 123 | let violatingIndexes = potentialViolatingIndexes.filter { $0 < lastMatchingIndex } 124 | for violatingIndex in violatingIndexes { 125 | let groupType = self.groupType(for: children[violatingIndex]).rawValue 126 | let expected = expectedGroupTypes.map { $0.rawValue }.joined(separator: ",") 127 | let name = self.name(for: children[violatingIndex]) 128 | let path = (parentPathComponents + [name]).joined(separator: "/") 129 | 130 | violations.append( 131 | FileViolation( 132 | rule: self, 133 | message: "The '\(groupType)' entry '\(path)' should not be placed amongst the group type(s) '\(expected)'.", 134 | level: defaultViolationLevel, 135 | url: projectUrl 136 | ) 137 | ) 138 | } 139 | } 140 | 141 | for group in group.groupChildren { 142 | violations += self.orderViolations( 143 | forChildrenIn: group, 144 | pbxproj: pbxproj, 145 | parentPathComponents: parentPathComponents + [name(for: group)], 146 | projectUrl: projectUrl 147 | ) 148 | } 149 | 150 | return violations 151 | } 152 | 153 | private func name(for element: Any) -> String { 154 | switch element { 155 | case let group as PBXGroup: 156 | return group.path ?? group.name! 157 | 158 | case let fileElement as PBXFileElement: 159 | return fileElement.path ?? fileElement.name! 160 | 161 | default: 162 | print("Found unexpected type in project group children.", level: .error) 163 | exit(EXIT_FAILURE) 164 | } 165 | } 166 | 167 | private func groupType(for element: Any) -> XcodeProjectNavigatorOptions.GroupType { 168 | if element is PBXGroup && !(element is PBXVariantGroup) { 169 | return .folder 170 | } 171 | 172 | let name = self.name(for: element) 173 | 174 | if name == "beak.swift" { 175 | return .other 176 | } 177 | 178 | if name.hasSuffix(".swift") || name.hasSuffix(".h") || name.hasSuffix(".m") || name.hasSuffix(".mm") { 179 | return .codeFile 180 | } 181 | 182 | if name.hasSuffix(".storyboard") || name.hasSuffix(".xib") { 183 | return .interface 184 | } 185 | 186 | if name.hasSuffix(".xcassets") { 187 | return .assets 188 | } 189 | 190 | if name.hasSuffix(".strings") || name.hasSuffix(".stringsdict") { 191 | return .strings 192 | } 193 | 194 | if name.hasSuffix(".entitlements") { 195 | return .entitlement 196 | } 197 | 198 | if name.hasSuffix(".plist") { 199 | return .plist 200 | } 201 | 202 | return .other 203 | } 204 | 205 | // private func groupChildren(of group: PBXGroup, pbxproj: PBXProj) -> [PBXGroup] { 206 | // return children(of: group, pbxproj: pbxproj).filter { !($0 is PBXVariantGroup) }.compactMap { $0 as? PBXGroup } 207 | // } 208 | // 209 | // private func children(of group: PBXGroup, pbxproj: PBXProj) -> [Any] { 210 | // let groups = pbxproj.groups 211 | // let variantGroups = pbxproj.variantGroups 212 | // let filesReferences = pbxproj.fileReferences 213 | // return group.children.compactMap { groups[$0] ?? variantGroups[$0] ?? filesReferences[$0] } 214 | // } 215 | 216 | private func violations( 217 | for substructure: [XcodeProjectNavigatorOptions.TreeNode], 218 | in pbxproj: PBXProj, 219 | parentPathComponents: [String], 220 | projectUrl: URL 221 | ) -> [Violation] { 222 | var violations = [Violation]() 223 | 224 | for node in substructure { 225 | switch node { 226 | case let .leaf(fileName): 227 | if !entryExists(at: parentPathComponents + [fileName], in: pbxproj) { 228 | let parentPath = parentPathComponents.isEmpty ? "root" : parentPathComponents.joined(separator: "/") 229 | violations.append( 230 | FileViolation( 231 | rule: self, 232 | message: "Expected to find entry '\(fileName)' in path '\(parentPath)' within the project navigator.", 233 | level: defaultViolationLevel, 234 | url: projectUrl 235 | ) 236 | ) 237 | } 238 | 239 | case let .subtree(groupName, subnodes): 240 | if !entryExists(at: parentPathComponents + [groupName], in: pbxproj) { 241 | let parentPath = parentPathComponents.isEmpty ? "root" : parentPathComponents.joined(separator: "/") 242 | violations.append( 243 | FileViolation( 244 | rule: self, 245 | message: "Expected to find entry '\(groupName)' in path '\(parentPath)' within the project navigator.", 246 | level: defaultViolationLevel, 247 | url: projectUrl 248 | ) 249 | ) 250 | } else { 251 | violations += self.violations(for: subnodes, in: pbxproj, parentPathComponents: parentPathComponents + [groupName], projectUrl: projectUrl) 252 | } 253 | } 254 | } 255 | 256 | // TODO: also make sure the given order within the structure is correct 257 | 258 | return violations 259 | } 260 | 261 | private func entryExists(at pathComponents: [String], in pbxproj: PBXProj) -> Bool { 262 | var currentGroup: PBXGroup = try! pbxproj.rootGroup()! // swiftlint:disable:this force_try 263 | 264 | for pathComponent in pathComponents.dropLast() { 265 | let groupChildren = currentGroup.groupChildren 266 | currentGroup = groupChildren.first { $0.path == pathComponent || $0.name == pathComponent }! 267 | } 268 | 269 | return currentGroup.children.contains { found in 270 | switch found { 271 | case let group as PBXGroup: 272 | return group.path ?? group.name == pathComponents.last! 273 | 274 | default: 275 | return found.path ?? found.name == pathComponents.last! 276 | } 277 | } 278 | } 279 | 280 | private func childrenAreOrdered(order orderedChildren: [String], inGroup groupPathComponents: [String], pbxproj: PBXProj) -> Bool { 281 | // TODO: not yet implemented 282 | return true 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | // Generated using Sourcery 0.15.0 — https://github.com/krzysztofzablocki/Sourcery 2 | // DO NOT EDIT 3 | 4 | @testable import ProjLintKitTests 5 | import XCTest 6 | 7 | // swiftlint:disable line_length file_length 8 | 9 | extension FileContentRegexOptionsTests { 10 | static var allTests: [(String, (FileContentRegexOptionsTests) -> () throws -> Void)] = [ 11 | ("testInitWithMatchingPathRegex", testInitWithMatchingPathRegex), 12 | ("testInitWithMatchingAllPathRegexes", testInitWithMatchingAllPathRegexes), 13 | ("testInitWithMatchingAnyPathRegexes", testInitWithMatchingAnyPathRegexes), 14 | ("testInitWithNotMatchingPathRegex", testInitWithNotMatchingPathRegex), 15 | ("testInitWithNotMatchingAllPathRegexes", testInitWithNotMatchingAllPathRegexes), 16 | ("testInitWithNotMatchingAnyPathRegexes", testInitWithNotMatchingAnyPathRegexes) 17 | ] 18 | } 19 | 20 | extension FileContentRegexRuleTests { 21 | static var allTests: [(String, (FileContentRegexRuleTests) -> () throws -> Void)] = [ 22 | ("testMatchingRegex", testMatchingRegex), 23 | ("testMatchingAllPathRegexes", testMatchingAllPathRegexes), 24 | ("testMatchingAnyPathRegexes", testMatchingAnyPathRegexes), 25 | ("testNotMatchingRegex", testNotMatchingRegex), 26 | ("testNotMatchingAllPathRegexes", testNotMatchingAllPathRegexes), 27 | ("testNotMatchingAnyPathRegexes", testNotMatchingAnyPathRegexes) 28 | ] 29 | } 30 | 31 | extension FileContentTemplateOptionsTests { 32 | static var allTests: [(String, (FileContentTemplateOptionsTests) -> () throws -> Void)] = [ 33 | ("testInitWithMatchingPathTemplateViaPath", testInitWithMatchingPathTemplateViaPath), 34 | ("testInitWithMatchingPathTemplateViaURL", testInitWithMatchingPathTemplateViaURL) 35 | ] 36 | } 37 | 38 | extension FileContentTemplateRuleTests { 39 | static var allTests: [(String, (FileContentTemplateRuleTests) -> () throws -> Void)] = [ 40 | ("testMatchingPathTemplateViaPath", testMatchingPathTemplateViaPath) 41 | ] 42 | } 43 | 44 | extension FileExistenceOptionsTests { 45 | static var allTests: [(String, (FileExistenceOptionsTests) -> () throws -> Void)] = [ 46 | ("testInitWithExistingPaths", testInitWithExistingPaths), 47 | ("testInitWithNonExistingPaths", testInitWithNonExistingPaths) 48 | ] 49 | } 50 | 51 | extension FileExistenceRuleTests { 52 | static var allTests: [(String, (FileExistenceRuleTests) -> () throws -> Void)] = [ 53 | ("testExistingPaths", testExistingPaths), 54 | ("testNonExistingPaths", testNonExistingPaths) 55 | ] 56 | } 57 | 58 | extension XcodeBuildPhasesOptionsTests { 59 | static var allTests: [(String, (XcodeBuildPhasesOptionsTests) -> () throws -> Void)] = [ 60 | ("testInitWithAllOptions", testInitWithAllOptions) 61 | ] 62 | } 63 | 64 | extension XcodeBuildPhasesRuleTests { 65 | static var allTests: [(String, (XcodeBuildPhasesRuleTests) -> () throws -> Void)] = [ 66 | ("testAllOptions", testAllOptions) 67 | ] 68 | } 69 | 70 | extension XcodeProjectNavigatorOptionsTests { 71 | static var allTests: [(String, (XcodeProjectNavigatorOptionsTests) -> () throws -> Void)] = [ 72 | ("testInitWithAllOptions", testInitWithAllOptions) 73 | ] 74 | } 75 | 76 | extension XcodeProjectNavigatorRuleTests { 77 | static var allTests: [(String, (XcodeProjectNavigatorRuleTests) -> () throws -> Void)] = [ 78 | ("testWithAllOptions", testWithAllOptions) 79 | ] 80 | } 81 | 82 | XCTMain([ 83 | testCase(FileContentRegexOptionsTests.allTests), 84 | testCase(FileContentRegexRuleTests.allTests), 85 | testCase(FileContentTemplateOptionsTests.allTests), 86 | testCase(FileContentTemplateRuleTests.allTests), 87 | testCase(FileExistenceOptionsTests.allTests), 88 | testCase(FileExistenceRuleTests.allTests), 89 | testCase(XcodeBuildPhasesOptionsTests.allTests), 90 | testCase(XcodeBuildPhasesRuleTests.allTests), 91 | testCase(XcodeProjectNavigatorOptionsTests.allTests), 92 | testCase(XcodeProjectNavigatorRuleTests.allTests) 93 | ]) 94 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Globals/Faker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | 4 | indirect enum FakerType { 5 | case bool 6 | case text 7 | case variable 8 | case regexString 9 | case filePath 10 | case dirPath 11 | case fileURL 12 | case array(elementType: FakerType, count: Int) 13 | case dict(keyType: FakerType, valueType: FakerType, count: Int) 14 | } 15 | 16 | struct Faker { 17 | static let first = Faker(seed: 1) 18 | static let second = Faker(seed: 2) 19 | static let third = Faker(seed: 3) 20 | 21 | private let seed: String 22 | 23 | // MARK: - Fake Data Generators 24 | private var bool: Bool { 25 | return Int(seed)! % 2 == 0 26 | } 27 | 28 | private var text: String { 29 | return "example text \(seed)" 30 | } 31 | 32 | private var variable: String { 33 | return "exampleVar\(seed)" 34 | } 35 | 36 | private var regexString: String { 37 | return ".*\(seed)\\.swift" 38 | } 39 | 40 | private var filePath: String { 41 | return "path/to/some/file\(seed).swift" 42 | } 43 | 44 | private var fileURL: String { 45 | return "https://github.com/User/Project/blob/stable/File\(seed).swift" 46 | } 47 | 48 | private var dirPath: String { 49 | return "path/to/some/directory\(seed)/" 50 | } 51 | 52 | // MARK: - Initializers 53 | private init(seed: Int, superseed: String? = nil) { 54 | if let superseed = superseed { 55 | self.seed = "\(superseed).\(seed)" 56 | } else { 57 | self.seed = String(seed) 58 | } 59 | } 60 | 61 | // MARK: - Instance Methods 62 | func data(ofType type: FakerType) -> Any { 63 | switch type { 64 | case .bool: 65 | return bool 66 | 67 | case .text: 68 | return text 69 | 70 | case .variable: 71 | return variable 72 | 73 | case .regexString: 74 | return regexString 75 | 76 | case .filePath: 77 | return filePath 78 | 79 | case .dirPath: 80 | return dirPath 81 | 82 | case .fileURL: 83 | return fileURL 84 | 85 | case let .array(elementType, count): 86 | return array(elementType: elementType, count: count) 87 | 88 | case let .dict(keyType, valueType, count): 89 | return dict(keyType: keyType, valueType: valueType, count: count) 90 | } 91 | } 92 | 93 | private func array(elementType: FakerType, count: Int) -> [Any] { 94 | return (0 ..< count).map { Faker(seed: $0, superseed: seed).data(ofType: elementType) } 95 | } 96 | 97 | private func dict(keyType: FakerType, valueType: FakerType, count: Int) -> [String: Any] { 98 | let keys = (0 ..< count).map { Faker(seed: $0, superseed: seed).data(ofType: keyType) as! String } 99 | let values = (0 ..< count).map { Faker(seed: $0, superseed: seed).data(ofType: valueType) } 100 | return Dictionary(keys: keys, values: values)! 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Globals/Resource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HandySwift 3 | @testable import ProjLintKit 4 | import XCTest 5 | 6 | struct Resource { 7 | static let baseUrl = FileManager.default.currentDirectoryUrl.appendingPathComponent(".testResources", isDirectory: true) 8 | 9 | let contents: String 10 | 11 | // When using a projlint.yml file, all paths used there are relative. 12 | // Thus, in order to ensure that during tests the paths used are also relative, 13 | // only the relativePath is exposed, and not the whole URL object 14 | var relativePath: String { 15 | return url.relativePath 16 | } 17 | 18 | // The url is declared fileprivate in order to use it in the only place that an 19 | // absolute path is needed: at resource creation (the extension below) 20 | fileprivate let url: URL 21 | 22 | var data: Data? { 23 | return contents.data(using: .utf8) 24 | } 25 | 26 | init(path: String, contents: String) { 27 | self.url = URL(fileURLWithPath: path, relativeTo: Resource.baseUrl) 28 | self.contents = contents 29 | } 30 | } 31 | 32 | // swiftlint:disable force_try 33 | 34 | extension XCTestCase { 35 | func resourcesLoaded(_ resources: [Resource], testCode: () -> Void) { 36 | try! FileManager.default.removeContentsOfDirectory(at: Resource.baseUrl) 37 | 38 | for resource in resources { 39 | try! FileManager.default.createFile(at: resource.url, withIntermediateDirectories: true, contents: resource.data, attributes: nil) 40 | } 41 | 42 | testCode() 43 | try! FileManager.default.removeContentsOfDirectory(at: Resource.baseUrl) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Globals/ResourceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class ResourceTest: XCTestCase { 4 | private let resource = Resource( 5 | path: "directory/Resource.swift", 6 | contents: "" 7 | ) 8 | 9 | func testRelativePath() { 10 | XCTAssertEqual(resource.relativePath, "directory/Resource.swift") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileContentRegexOptionsTests.swift: -------------------------------------------------------------------------------- 1 | import HandySwift 2 | @testable import ProjLintKit 3 | import XCTest 4 | 5 | final class FileContentRegexOptionsTests: XCTestCase { 6 | func testInitWithMatchingPathRegex() { 7 | let valueType = FakerType.dict(keyType: .filePath, valueType: .regexString, count: 5) 8 | let optionsDict = ["matching": Faker.first.data(ofType: valueType)] 9 | 10 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 11 | 12 | XCTAssert(options.matchingPathRegex != nil) 13 | XCTAssertEqual(options.matchingPathRegex!.count, 5) 14 | } 15 | 16 | func testInitWithMatchingAllPathRegexes() { 17 | let valueType = FakerType.dict(keyType: .filePath, valueType: .array(elementType: .regexString, count: 3), count: 5) 18 | let optionsDict = ["matching_all": Faker.first.data(ofType: valueType)] 19 | 20 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 21 | 22 | XCTAssert(options.matchingAllPathRegexes != nil) 23 | XCTAssertEqual(options.matchingAllPathRegexes!.count, 5) 24 | 25 | let firstPathRegexes: [Regex] = options.matchingAllPathRegexes!.values.first! 26 | XCTAssertEqual(firstPathRegexes.count, 3) 27 | } 28 | 29 | func testInitWithMatchingAnyPathRegexes() { 30 | let valueType = FakerType.dict(keyType: .filePath, valueType: .array(elementType: .regexString, count: 3), count: 5) 31 | let optionsDict = ["matching_any": Faker.first.data(ofType: valueType)] 32 | 33 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 34 | 35 | XCTAssert(options.matchingAnyPathRegexes != nil) 36 | XCTAssertEqual(options.matchingAnyPathRegexes!.count, 5) 37 | 38 | let firstPathRegexes: [Regex] = options.matchingAnyPathRegexes!.values.first! 39 | XCTAssertEqual(firstPathRegexes.count, 3) 40 | } 41 | 42 | func testInitWithNotMatchingPathRegex() { 43 | let valueType = FakerType.dict(keyType: .filePath, valueType: .regexString, count: 5) 44 | let optionsDict = ["not_matching": Faker.first.data(ofType: valueType)] 45 | 46 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 47 | 48 | XCTAssert(options.notMatchingPathRegex != nil) 49 | XCTAssertEqual(options.notMatchingPathRegex!.count, 5) 50 | } 51 | 52 | func testInitWithNotMatchingAllPathRegexes() { 53 | let valueType = FakerType.dict(keyType: .filePath, valueType: .array(elementType: .regexString, count: 3), count: 5) 54 | let optionsDict = ["not_matching_all": Faker.first.data(ofType: valueType)] 55 | 56 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 57 | 58 | XCTAssert(options.notMatchingAllPathRegexes != nil) 59 | XCTAssertEqual(options.notMatchingAllPathRegexes!.count, 5) 60 | 61 | let firstPathRegexes: [Regex] = options.notMatchingAllPathRegexes!.values.first! 62 | XCTAssertEqual(firstPathRegexes.count, 3) 63 | } 64 | 65 | func testInitWithNotMatchingAnyPathRegexes() { 66 | let valueType = FakerType.dict(keyType: .filePath, valueType: .array(elementType: .regexString, count: 3), count: 5) 67 | let optionsDict = ["not_matching_any": Faker.first.data(ofType: valueType)] 68 | 69 | let options = FileContentRegexOptions(optionsDict, rule: FileContentRegexRule.self) 70 | 71 | XCTAssert(options.notMatchingAnyPathRegexes != nil) 72 | XCTAssertEqual(options.notMatchingAnyPathRegexes!.count, 5) 73 | 74 | let firstPathRegexes: [Regex] = options.notMatchingAnyPathRegexes!.values.first! 75 | XCTAssertEqual(firstPathRegexes.count, 3) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileContentRegexRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | final class FileContentRegexRuleTests: XCTestCase { 5 | let fruitEnumResource = Resource( 6 | path: "FruitEnum.swift", 7 | contents: """ 8 | enum Fruit { 9 | case apple 10 | case banana 11 | case orange 12 | } 13 | """ 14 | ) 15 | 16 | func testMatchingRegex() { 17 | resourcesLoaded([fruitEnumResource]) { 18 | let optionsDict = ["matching": [fruitEnumResource.relativePath: "enum\\s+Fruit\\s+\\{"]] 19 | let rule = FileContentRegexRule(optionsDict) 20 | 21 | let violations = rule.violations(in: Resource.baseUrl) 22 | XCTAssertEqual(violations.count, 0) 23 | } 24 | 25 | resourcesLoaded([fruitEnumResource]) { 26 | let optionsDict = ["matching": [fruitEnumResource.relativePath: "enum\\s+Vegetable\\s+\\{"]] 27 | let rule = FileContentRegexRule(optionsDict) 28 | 29 | let violations = rule.violations(in: Resource.baseUrl) 30 | XCTAssertEqual(violations.count, 1) 31 | } 32 | } 33 | 34 | func testMatchingAllPathRegexes() { 35 | resourcesLoaded([fruitEnumResource]) { 36 | let optionsDict = ["matching_all": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+banana", "case\\s+orange"]]] 37 | let rule = FileContentRegexRule(optionsDict) 38 | 39 | let violations = rule.violations(in: Resource.baseUrl) 40 | XCTAssertEqual(violations.count, 0) 41 | } 42 | 43 | resourcesLoaded([fruitEnumResource]) { 44 | let optionsDict = ["matching_all": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+grapefruit", "case\\s+pineapple"]]] 45 | let rule = FileContentRegexRule(optionsDict) 46 | 47 | let violations = rule.violations(in: Resource.baseUrl) 48 | XCTAssertEqual(violations.count, 2) 49 | } 50 | } 51 | 52 | func testMatchingAnyPathRegexes() { 53 | resourcesLoaded([fruitEnumResource]) { 54 | let optionsDict = ["matching_any": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+grapefruit", "case\\s+pineapple"]]] 55 | let rule = FileContentRegexRule(optionsDict) 56 | 57 | let violations = rule.violations(in: Resource.baseUrl) 58 | XCTAssertEqual(violations.count, 0) 59 | } 60 | 61 | resourcesLoaded([fruitEnumResource]) { 62 | let optionsDict = ["matching_any": [fruitEnumResource.relativePath: ["case\\s+kiwi", "case\\s+grapefruit", "case\\s+pineapple"]]] 63 | let rule = FileContentRegexRule(optionsDict) 64 | 65 | let violations = rule.violations(in: Resource.baseUrl) 66 | XCTAssertEqual(violations.count, 1) 67 | } 68 | } 69 | 70 | func testNotMatchingRegex() { 71 | resourcesLoaded([fruitEnumResource]) { 72 | let optionsDict = ["not_matching": [fruitEnumResource.relativePath: "enum\\s+Fruit\\s+\\{"]] 73 | let rule = FileContentRegexRule(optionsDict) 74 | 75 | let violations = rule.violations(in: Resource.baseUrl) 76 | XCTAssertEqual(violations.count, 1) 77 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.line }, [1]) 78 | } 79 | 80 | resourcesLoaded([fruitEnumResource]) { 81 | let optionsDict = ["not_matching": [fruitEnumResource.relativePath: "case"]] 82 | let rule = FileContentRegexRule(optionsDict) 83 | 84 | let violations = rule.violations(in: Resource.baseUrl) 85 | XCTAssertEqual(violations.count, 3) 86 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.line }, [2, 3, 4]) 87 | } 88 | 89 | resourcesLoaded([fruitEnumResource]) { 90 | let optionsDict = ["not_matching": [fruitEnumResource.relativePath: "enum\\s+Vegetable\\s+\\{"]] 91 | let rule = FileContentRegexRule(optionsDict) 92 | 93 | let violations = rule.violations(in: Resource.baseUrl) 94 | XCTAssertEqual(violations.count, 0) 95 | } 96 | } 97 | 98 | func testNotMatchingAllPathRegexes() { 99 | resourcesLoaded([fruitEnumResource]) { 100 | let optionsDict = ["not_matching_all": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+banana", "case\\s+orange"]]] 101 | let rule = FileContentRegexRule(optionsDict) 102 | 103 | let violations = rule.violations(in: Resource.baseUrl) 104 | XCTAssertEqual(violations.count, 3) 105 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.line }, [2, 3, 4]) 106 | } 107 | 108 | resourcesLoaded([fruitEnumResource]) { 109 | let optionsDict = ["not_matching_all": [fruitEnumResource.relativePath: ["case", "banana", "orange"]]] 110 | let rule = FileContentRegexRule(optionsDict) 111 | 112 | let violations = rule.violations(in: Resource.baseUrl) 113 | XCTAssertEqual(violations.count, 5) 114 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.line }, [2, 3, 4, 3, 4]) 115 | } 116 | 117 | resourcesLoaded([fruitEnumResource]) { 118 | let optionsDict = ["not_matching_all": [fruitEnumResource.relativePath: ["case", "banana", "grapefruit"]]] 119 | let rule = FileContentRegexRule(optionsDict) 120 | 121 | let violations = rule.violations(in: Resource.baseUrl) 122 | XCTAssertEqual(violations.count, 0) 123 | } 124 | 125 | resourcesLoaded([fruitEnumResource]) { 126 | let optionsDict = ["not_matching_all": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+grapefruit", "case\\s+pineapple"]]] 127 | let rule = FileContentRegexRule(optionsDict) 128 | 129 | let violations = rule.violations(in: Resource.baseUrl) 130 | XCTAssertEqual(violations.count, 0) 131 | } 132 | } 133 | 134 | func testNotMatchingAnyPathRegexes() { 135 | resourcesLoaded([fruitEnumResource]) { 136 | let optionsDict = ["not_matching_any": [fruitEnumResource.relativePath: ["case\\s+apple", "case\\s+banana", "case\\s+pineapple"]]] 137 | let rule = FileContentRegexRule(optionsDict) 138 | 139 | let violations = rule.violations(in: Resource.baseUrl) 140 | XCTAssertEqual(violations.count, 2) 141 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.line }, [2, 3]) 142 | } 143 | 144 | resourcesLoaded([fruitEnumResource]) { 145 | let optionsDict = ["not_matching_any": [fruitEnumResource.relativePath: ["case\\s+kiwi", "case\\s+grapefruit", "case\\s+pineapple"]]] 146 | let rule = FileContentRegexRule(optionsDict) 147 | 148 | let violations = rule.violations(in: Resource.baseUrl) 149 | XCTAssertEqual(violations.count, 0) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileContentTemplateOptionsTests.swift: -------------------------------------------------------------------------------- 1 | import HandySwift 2 | @testable import ProjLintKit 3 | import XCTest 4 | 5 | final class FileContentTemplateOptionsTests: XCTestCase { 6 | func testInitWithMatchingPathTemplateViaPath() { 7 | let parametersType = FakerType.dict(keyType: .variable, valueType: .text, count: 3) 8 | let optionsDict: [String: Any] = [ 9 | "matching": [ 10 | (Faker.first.data(ofType: .filePath) as! String): [ 11 | "template_path": Faker.first.data(ofType: .filePath), 12 | "parameters": Faker.first.data(ofType: parametersType) 13 | ] 14 | ] 15 | ] 16 | let options = FileContentTemplateOptions(optionsDict, rule: FileContentTemplateRule.self) 17 | XCTAssertEqual(options.matchingPathTemplate.count, 1) 18 | } 19 | 20 | func testInitWithMatchingPathTemplateViaURL() { 21 | let parametersType = FakerType.dict(keyType: .variable, valueType: .text, count: 3) 22 | let optionsDict: [String: Any] = [ 23 | "matching": [ 24 | (Faker.first.data(ofType: .filePath) as! String): [ 25 | "template_url": Faker.first.data(ofType: .fileURL), 26 | "parameters": Faker.first.data(ofType: parametersType) 27 | ] 28 | ] 29 | ] 30 | let options = FileContentTemplateOptions(optionsDict, rule: FileContentTemplateRule.self) 31 | XCTAssertEqual(options.matchingPathTemplate.count, 1) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileContentTemplateRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | final class FileContentTemplateRuleTests: XCTestCase { 5 | let swiftlintConfigExample = Resource( 6 | path: ".swiftlint.yml", 7 | contents: """ 8 | # Basic Configuration 9 | opt_in_rules: 10 | - attributes 11 | - empty_count 12 | - sorted_imports 13 | 14 | disabled_rules: 15 | - type_name 16 | 17 | included: 18 | - Sources 19 | - Tests 20 | 21 | # Rule Configurations 22 | identifier_name: 23 | excluded: 24 | - id 25 | 26 | line_length: 160 27 | 28 | """ 29 | ) 30 | 31 | let swiftlintConfigTemplate = Resource( 32 | path: "SwiftLint.stencil", 33 | contents: """ 34 | # Basic Configuration 35 | opt_in_rules:{% for rule in additionalRules %}\n- {{ rule }}{% endfor %} 36 | 37 | disabled_rules: 38 | - type_name 39 | 40 | included: 41 | - Sources 42 | - Tests 43 | 44 | # Rule Configurations 45 | identifier_name: 46 | excluded: 47 | - id 48 | 49 | line_length: {{ lineLength }} 50 | 51 | """ 52 | ) 53 | 54 | func testMatchingPathTemplateViaPath() { 55 | resourcesLoaded([swiftlintConfigExample, swiftlintConfigTemplate]) { 56 | let optionsDict = [ 57 | "matching": [ 58 | swiftlintConfigExample.relativePath: [ 59 | "template_path": swiftlintConfigTemplate.relativePath, 60 | "parameters": [ 61 | "additionalRules": ["attributes", "empty_count", "sorted_imports"], 62 | "lineLength": "160" 63 | ] 64 | ] 65 | ] 66 | ] 67 | let rule = FileContentTemplateRule(optionsDict) 68 | 69 | let violations = rule.violations(in: Resource.baseUrl) 70 | XCTAssertEqual(violations.count, 0) 71 | } 72 | 73 | resourcesLoaded([swiftlintConfigExample, swiftlintConfigTemplate]) { 74 | let optionsDict = [ 75 | "matching": [ 76 | swiftlintConfigExample.relativePath: [ 77 | "template_path": swiftlintConfigTemplate.relativePath, 78 | "parameters": [ 79 | "additionalRules": ["attributes", "sorted_imports", "yoda_condition"], 80 | "lineLength": "80" 81 | ] 82 | ] 83 | ] 84 | ] 85 | let rule = FileContentTemplateRule(optionsDict) 86 | 87 | let violations = rule.violations(in: Resource.baseUrl) 88 | XCTAssertEqual(violations.count, 1) 89 | XCTAssert(violations.first is FileViolation) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileExistenceOptionsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | final class FileExistenceOptionsTests: XCTestCase { 5 | func testInitWithExistingPaths() { 6 | let valueType = FakerType.array(elementType: .filePath, count: 5) 7 | let optionsDict = ["existing_paths": Faker.first.data(ofType: valueType)] 8 | 9 | let options = FileExistenceOptions(optionsDict, rule: FileExistenceRule.self) 10 | 11 | XCTAssert(options.existingPaths != nil) 12 | XCTAssertEqual(options.existingPaths!.count, 5) 13 | } 14 | 15 | func testInitWithNonExistingPaths() { 16 | let valueType = FakerType.array(elementType: .filePath, count: 5) 17 | let optionsDict = ["non_existing_paths": Faker.first.data(ofType: valueType)] 18 | 19 | let options = FileExistenceOptions(optionsDict, rule: FileExistenceRule.self) 20 | 21 | XCTAssert(options.nonExistingPaths != nil) 22 | XCTAssertEqual(options.nonExistingPaths!.count, 5) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/FileExistenceRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | final class FileExistenceRuleTests: XCTestCase { 5 | let infoPlistResource = Resource(path: "Sources/SuportingFiles/Info.plist", contents: "") 6 | 7 | func testExistingPaths() { 8 | resourcesLoaded([infoPlistResource]) { 9 | let optionsDict = ["existing_paths": [infoPlistResource.relativePath]] 10 | let rule = FileExistenceRule(optionsDict) 11 | 12 | let violations = rule.violations(in: Resource.baseUrl) 13 | XCTAssert(violations.isEmpty) 14 | } 15 | 16 | resourcesLoaded([]) { 17 | let optionsDict = ["existing_paths": [infoPlistResource.relativePath]] 18 | let rule = FileExistenceRule(optionsDict) 19 | 20 | let violations = rule.violations(in: Resource.baseUrl) 21 | XCTAssertEqual(violations.count, 1) 22 | } 23 | } 24 | 25 | func testNonExistingPaths() { 26 | resourcesLoaded([infoPlistResource]) { 27 | let optionsDict = ["non_existing_paths": [infoPlistResource.relativePath]] 28 | let rule = FileExistenceRule(optionsDict) 29 | 30 | let violations = rule.violations(in: Resource.baseUrl) 31 | XCTAssertEqual(violations.count, 1) 32 | } 33 | 34 | resourcesLoaded([]) { 35 | let optionsDict = ["non_existing_paths": [infoPlistResource.relativePath]] 36 | let rule = FileExistenceRule(optionsDict) 37 | 38 | let violations = rule.violations(in: Resource.baseUrl) 39 | XCTAssert(violations.isEmpty) 40 | } 41 | } 42 | 43 | func testAllowedPathsWithValidRegex() { 44 | resourcesLoaded([infoPlistResource]) { 45 | let optionsDict = ["allowed_paths_regex": [#"Sources/SuportingFiles/Info\.plist"#]] 46 | let rule = FileExistenceRule(optionsDict) 47 | 48 | let violations = rule.violations(in: Resource.baseUrl) 49 | XCTAssertEqual(violations.count, 0) 50 | } 51 | 52 | resourcesLoaded([infoPlistResource]) { 53 | let optionsDict = ["allowed_paths_regex": [#"Sources/SuportingFiles/Info2\.plist"#]] 54 | let rule = FileExistenceRule(optionsDict) 55 | 56 | let violations = rule.violations(in: Resource.baseUrl) 57 | XCTAssertEqual(violations.count, 1) 58 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.url.relativePath }, [infoPlistResource.relativePath]) 59 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.message }, ["File exists, but it mustn\'t."]) 60 | } 61 | 62 | resourcesLoaded([infoPlistResource]) { 63 | let optionsDict = ["allowed_paths_regex": [#"Sources/SuportingFiles/.*"#]] 64 | let rule = FileExistenceRule(optionsDict) 65 | 66 | let violations = rule.violations(in: Resource.baseUrl) 67 | XCTAssertEqual(violations.count, 0) 68 | } 69 | 70 | resourcesLoaded([infoPlistResource]) { 71 | let optionsDict = ["allowed_paths_regex": [#".*"#]] 72 | let rule = FileExistenceRule(optionsDict) 73 | 74 | let violations = rule.violations(in: Resource.baseUrl) 75 | XCTAssertEqual(violations.count, 0) 76 | } 77 | 78 | resourcesLoaded([infoPlistResource]) { 79 | let optionsDict = ["allowed_paths_regex": [#".*\.png"#]] 80 | let rule = FileExistenceRule(optionsDict) 81 | 82 | let violations = rule.violations(in: Resource.baseUrl) 83 | XCTAssertEqual(violations.count, 1) 84 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.url.relativePath }, [infoPlistResource.relativePath]) 85 | XCTAssertEqual(violations.compactMap { ($0 as? FileViolation)?.message }, ["File exists, but it mustn\'t."]) 86 | } 87 | } 88 | 89 | func testAllowedPathsWithInvalidRegex() { 90 | let invalidRegex = #"["# 91 | let optionsDict = ["allowed_paths_regex": [invalidRegex]] 92 | let rule = FileExistenceRule(optionsDict) 93 | 94 | let violations = rule.violations(in: Resource.baseUrl) 95 | XCTAssertEqual(violations.count, 1) 96 | XCTAssertEqual(violations.map { $0.message }, ["The following regex is not valid: \'[\'"]) 97 | } 98 | 99 | func testAllowedPathsWithSamePathInOtherRules() { 100 | resourcesLoaded([infoPlistResource]) { 101 | let optionsDict = [ 102 | "existing_paths": [infoPlistResource.relativePath], 103 | "allowed_paths_regex": [#"ThisIsARandomPathThatDoesNotMatch"#] 104 | ] 105 | let rule = FileExistenceRule(optionsDict) 106 | 107 | let violations = rule.violations(in: Resource.baseUrl) 108 | XCTAssertEqual(violations.count, 0) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/XcodeBuildPhasesOptionsTests.swift: -------------------------------------------------------------------------------- 1 | import HandySwift 2 | @testable import ProjLintKit 3 | import XCTest 4 | 5 | final class XcodeBuildPhasesOptionsTests: XCTestCase { 6 | func testInitWithAllOptions() { 7 | let runScriptsValueType = FakerType.dict(keyType: .filePath, valueType: .text, count: 3) 8 | let optionsDict = [ 9 | "project_path": Faker.first.data(ofType: .filePath), 10 | "target_name": Faker.first.data(ofType: .text), 11 | "run_scripts": Faker.first.data(ofType: runScriptsValueType) 12 | ] 13 | 14 | let options = XcodeBuildPhasesOptions(optionsDict, rule: XcodeBuildPhasesRule.self) 15 | 16 | XCTAssertEqual(options.runScripts.count, 3) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/XcodeBuildPhasesRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | // swiftlint:disable type_body_length line_length 5 | 6 | final class XcodeBuildPhasesRuleTests: XCTestCase { 7 | private static let xcprojPath: String = "Example.xcodeproj" 8 | let xcprojResource = Resource( 9 | path: "\(xcprojPath)/project.pbxproj", 10 | contents: """ 11 | // !$*UTF8*$! 12 | { 13 | archiveVersion = 1; 14 | classes = { 15 | }; 16 | objectVersion = 46; 17 | objects = { 18 | 19 | /* Begin PBXBuildFile section */ 20 | OBJ_22 /* Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* Test.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | OBJ_9 /* Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test.swift; sourceTree = ""; }; 25 | "Test::Test::Product" /* Test.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = Test.framework; path = /Users/Arbeit/Desktop/Test/build/Debug/Test.framework; sourceTree = ""; }; 26 | /* End PBXFileReference section */ 27 | 28 | /* Begin PBXFrameworksBuildPhase section */ 29 | OBJ_23 /* Frameworks */ = { 30 | isa = PBXFrameworksBuildPhase; 31 | buildActionMask = 0; 32 | files = ( 33 | ); 34 | runOnlyForDeploymentPostprocessing = 0; 35 | }; 36 | /* End PBXFrameworksBuildPhase section */ 37 | 38 | /* Begin PBXGroup section */ 39 | OBJ_5 = { 40 | isa = PBXGroup; 41 | children = ( 42 | OBJ_7 /* Sources */, 43 | ); 44 | sourceTree = ""; 45 | }; 46 | OBJ_7 /* Sources */ = { 47 | isa = PBXGroup; 48 | children = ( 49 | OBJ_8 /* Test */, 50 | ); 51 | name = Sources; 52 | sourceTree = SOURCE_ROOT; 53 | }; 54 | OBJ_8 /* Test */ = { 55 | isa = PBXGroup; 56 | children = ( 57 | OBJ_9 /* Test.swift */, 58 | ); 59 | name = Test; 60 | path = Sources/Test; 61 | sourceTree = SOURCE_ROOT; 62 | }; 63 | /* End PBXGroup section */ 64 | 65 | /* Begin PBXNativeTarget section */ 66 | "Test::Test" /* Test */ = { 67 | isa = PBXNativeTarget; 68 | buildConfigurationList = OBJ_18 /* Build configuration list for PBXNativeTarget "Test" */; 69 | buildPhases = ( 70 | OBJ_21 /* Sources */, 71 | OBJ_23 /* Frameworks */, 72 | 8218959A211B281700CF5073 /* SwiftLint */, 73 | ); 74 | buildRules = ( 75 | ); 76 | dependencies = ( 77 | ); 78 | name = Test; 79 | productName = Test; 80 | productReference = "Test::Test::Product" /* Test.framework */; 81 | productType = "com.apple.product-type.framework"; 82 | }; 83 | /* End PBXNativeTarget section */ 84 | 85 | /* Begin PBXProject section */ 86 | OBJ_1 /* Project object */ = { 87 | isa = PBXProject; 88 | attributes = { 89 | LastUpgradeCheck = 9999; 90 | }; 91 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "Test" */; 92 | compatibilityVersion = "Xcode 3.2"; 93 | developmentRegion = English; 94 | hasScannedForEncodings = 0; 95 | knownRegions = ( 96 | en, 97 | ); 98 | mainGroup = OBJ_5; 99 | productRefGroup = OBJ_5; 100 | projectDirPath = ""; 101 | projectRoot = ""; 102 | targets = ( 103 | "Test::Test" /* Test */, 104 | ); 105 | }; 106 | /* End PBXProject section */ 107 | 108 | /* Begin PBXShellScriptBuildPhase section */ 109 | 8218959A211B281700CF5073 /* SwiftLint */ = { 110 | isa = PBXShellScriptBuildPhase; 111 | buildActionMask = 2147483647; 112 | files = ( 113 | ); 114 | inputPaths = ( 115 | ); 116 | name = SwiftLint; 117 | outputPaths = ( 118 | ); 119 | runOnlyForDeploymentPostprocessing = 0; 120 | shellPath = /bin/sh; 121 | shellScript = "if which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download it from https://github.com/realm/SwiftLint\"\nfi"; 122 | }; 123 | /* End PBXShellScriptBuildPhase section */ 124 | 125 | /* Begin PBXSourcesBuildPhase section */ 126 | OBJ_21 /* Sources */ = { 127 | isa = PBXSourcesBuildPhase; 128 | buildActionMask = 0; 129 | files = ( 130 | OBJ_22 /* Test.swift in Sources */, 131 | ); 132 | runOnlyForDeploymentPostprocessing = 0; 133 | }; 134 | /* End PBXSourcesBuildPhase section */ 135 | 136 | /* Begin XCBuildConfiguration section */ 137 | OBJ_19 /* Debug */ = { 138 | isa = XCBuildConfiguration; 139 | buildSettings = { 140 | ENABLE_TESTABILITY = YES; 141 | FRAMEWORK_SEARCH_PATHS = ( 142 | "$(inherited)", 143 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 144 | ); 145 | HEADER_SEARCH_PATHS = "$(inherited)"; 146 | INFOPLIST_FILE = Test.xcodeproj/Test_Info.plist; 147 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 148 | OTHER_CFLAGS = "$(inherited)"; 149 | OTHER_LDFLAGS = "$(inherited)"; 150 | OTHER_SWIFT_FLAGS = "$(inherited)"; 151 | PRODUCT_BUNDLE_IDENTIFIER = Test; 152 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 153 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 154 | SKIP_INSTALL = YES; 155 | SWIFT_VERSION = 4.0; 156 | TARGET_NAME = Test; 157 | }; 158 | name = Debug; 159 | }; 160 | OBJ_20 /* Release */ = { 161 | isa = XCBuildConfiguration; 162 | buildSettings = { 163 | ENABLE_TESTABILITY = YES; 164 | FRAMEWORK_SEARCH_PATHS = ( 165 | "$(inherited)", 166 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 167 | ); 168 | HEADER_SEARCH_PATHS = "$(inherited)"; 169 | INFOPLIST_FILE = Test.xcodeproj/Test_Info.plist; 170 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 171 | OTHER_CFLAGS = "$(inherited)"; 172 | OTHER_LDFLAGS = "$(inherited)"; 173 | OTHER_SWIFT_FLAGS = "$(inherited)"; 174 | PRODUCT_BUNDLE_IDENTIFIER = Test; 175 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 176 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 177 | SKIP_INSTALL = YES; 178 | SWIFT_VERSION = 4.0; 179 | TARGET_NAME = Test; 180 | }; 181 | name = Release; 182 | }; 183 | OBJ_3 /* Debug */ = { 184 | isa = XCBuildConfiguration; 185 | buildSettings = { 186 | CLANG_ENABLE_OBJC_ARC = YES; 187 | COMBINE_HIDPI_IMAGES = YES; 188 | COPY_PHASE_STRIP = NO; 189 | DEBUG_INFORMATION_FORMAT = dwarf; 190 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 191 | ENABLE_NS_ASSERTIONS = YES; 192 | GCC_OPTIMIZATION_LEVEL = 0; 193 | MACOSX_DEPLOYMENT_TARGET = 10.10; 194 | ONLY_ACTIVE_ARCH = YES; 195 | OTHER_SWIFT_FLAGS = "-DXcode"; 196 | PRODUCT_NAME = "$(TARGET_NAME)"; 197 | SDKROOT = macosx; 198 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 199 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 200 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 201 | USE_HEADERMAP = NO; 202 | }; 203 | name = Debug; 204 | }; 205 | OBJ_4 /* Release */ = { 206 | isa = XCBuildConfiguration; 207 | buildSettings = { 208 | CLANG_ENABLE_OBJC_ARC = YES; 209 | COMBINE_HIDPI_IMAGES = YES; 210 | COPY_PHASE_STRIP = YES; 211 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 212 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 213 | GCC_OPTIMIZATION_LEVEL = s; 214 | MACOSX_DEPLOYMENT_TARGET = 10.10; 215 | OTHER_SWIFT_FLAGS = "-DXcode"; 216 | PRODUCT_NAME = "$(TARGET_NAME)"; 217 | SDKROOT = macosx; 218 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 219 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 220 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 221 | USE_HEADERMAP = NO; 222 | }; 223 | name = Release; 224 | }; 225 | /* End XCBuildConfiguration section */ 226 | 227 | /* Begin XCConfigurationList section */ 228 | OBJ_18 /* Build configuration list for PBXNativeTarget "Test" */ = { 229 | isa = XCConfigurationList; 230 | buildConfigurations = ( 231 | OBJ_19 /* Debug */, 232 | OBJ_20 /* Release */, 233 | ); 234 | defaultConfigurationIsVisible = 0; 235 | defaultConfigurationName = Release; 236 | }; 237 | OBJ_2 /* Build configuration list for PBXProject "Test" */ = { 238 | isa = XCConfigurationList; 239 | buildConfigurations = ( 240 | OBJ_3 /* Debug */, 241 | OBJ_4 /* Release */, 242 | ); 243 | defaultConfigurationIsVisible = 0; 244 | defaultConfigurationName = Release; 245 | }; 246 | /* End XCConfigurationList section */ 247 | }; 248 | rootObject = OBJ_1 /* Project object */; 249 | } 250 | 251 | """ 252 | ) 253 | 254 | func testAllOptions() { 255 | resourcesLoaded([xcprojResource]) { 256 | let optionsDict: [String: Any] = [ 257 | "project_path": XcodeBuildPhasesRuleTests.xcprojPath, 258 | "target_name": "Test", 259 | "run_scripts": [ 260 | "SwiftLint": """ 261 | if which swiftlint > /dev/null; then 262 | swiftlint 263 | else 264 | echo "warning: SwiftLint not installed, download it from https://github.com/realm/SwiftLint" 265 | fi 266 | 267 | """ 268 | ] 269 | ] 270 | // TODO: re-enable once https://github.com/tuist/xcodeproj/issues/280 is solved 271 | // let rule = XcodeBuildPhasesRule(optionsDict) 272 | // 273 | // let violations = rule.violations(in: Resource.baseUrl) 274 | // XCTAssertEqual(violations.count, 0) 275 | } 276 | 277 | resourcesLoaded([xcprojResource]) { 278 | let optionsDict: [String: Any] = [ 279 | "project_path": XcodeBuildPhasesRuleTests.xcprojPath, 280 | "target_name": "Test", 281 | "run_scripts": [ 282 | "ProjLint": """ 283 | if which projlint > /dev/null; then 284 | projlint lint 285 | else 286 | echo "warning: ProjLint not installed, download it from https://github.com/JamitLabs/ProjLint" 287 | fi 288 | 289 | """ 290 | ] 291 | ] 292 | // TODO: re-enable once https://github.com/tuist/xcodeproj/issues/280 is solved 293 | // let rule = XcodeBuildPhasesRule(optionsDict) 294 | // 295 | // let violations = rule.violations(in: Resource.baseUrl) 296 | // XCTAssertEqual(violations.count, 1) 297 | } 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/XcodeProjectNavigatorOptionsTests.swift: -------------------------------------------------------------------------------- 1 | import HandySwift 2 | @testable import ProjLintKit 3 | import XCTest 4 | 5 | final class XcodeProjectNavigatorOptionsTests: XCTestCase { 6 | func testInitWithAllOptions() { 7 | let optionsDict: [String: Any] = [ 8 | "project_path": Faker.first.data(ofType: .filePath), 9 | "sorted": Faker.first.data(ofType: .array(elementType: .filePath, count: 5)), 10 | "inner_group_order": [ 11 | "assets", 12 | ["strings", "others"], 13 | "folders", 14 | ["interfaces", "code_files"] 15 | ], 16 | "structure": [ 17 | [ 18 | "App": [ 19 | [ 20 | "Resources": ["Test.swift"] 21 | ], 22 | "SupportingFiles" 23 | ] 24 | ], 25 | "Tests", 26 | [ 27 | "UITests": ["UITestExample.swift"] 28 | ], 29 | "Products" 30 | ] 31 | ] 32 | 33 | let options = XcodeProjectNavigatorOptions(optionsDict, rule: XcodeProjectNavigatorRule.self) 34 | 35 | XCTAssertEqual(options.innerGroupOrder.count, 4) 36 | XCTAssertEqual(options.sorted?.count, 5) 37 | XCTAssertEqual(options.structure.count, 4) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/ProjLintKitTests/Rules/XcodeProjectNavigatorRuleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ProjLintKit 2 | import XCTest 3 | 4 | // swiftlint:disable type_body_length line_length multiline_literal_brackets function_body_length 5 | 6 | final class XcodeProjectNavigatorRuleTests: XCTestCase { 7 | private static let xcprojPath: String = "Example.xcodeproj" 8 | let xcprojResource = Resource( 9 | path: "\(XcodeProjectNavigatorRuleTests.xcprojPath)/project.pbxproj", 10 | contents: """ 11 | // !$*UTF8*$! 12 | { 13 | archiveVersion = 1; 14 | classes = { 15 | }; 16 | objectVersion = 46; 17 | objects = { 18 | 19 | /* Begin PBXFileReference section */ 20 | 82189633211B47A600CF5073 /* TestB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestB.swift; sourceTree = ""; }; 21 | 82EA08BF211B47F1009CECE0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 22 | 82EA08C1211B4811009CECE0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 23 | 82EA08C2211B4821009CECE0 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 24 | 82EA08C3211B483C009CECE0 /* TestA.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TestA.xib; sourceTree = ""; }; 25 | 82EA08C4211B4844009CECE0 /* TestB.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TestB.xib; sourceTree = ""; }; 26 | 82EA08C5211B4857009CECE0 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; 27 | 82EA08C6211B48B7009CECE0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 28 | 82EA08C7211B4A82009CECE0 /* XyzController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XyzController.swift; sourceTree = ""; }; 29 | OBJ_12 /* TestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTests.swift; sourceTree = ""; }; 30 | OBJ_13 /* XCTestManifests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestManifests.swift; sourceTree = ""; }; 31 | OBJ_6 /* Package.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 32 | OBJ_9 /* TestA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestA.swift; sourceTree = ""; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXGroup section */ 36 | 82189635211B47B500CF5073 /* SupportingFiles */ = { 37 | isa = PBXGroup; 38 | children = ( 39 | 82EA08BF211B47F1009CECE0 /* Info.plist */, 40 | ); 41 | path = SupportingFiles; 42 | sourceTree = ""; 43 | }; 44 | 82EA08C0211B47FE009CECE0 /* SupportingFiles */ = { 45 | isa = PBXGroup; 46 | children = ( 47 | 82EA08C1211B4811009CECE0 /* Info.plist */, 48 | 82EA08C2211B4821009CECE0 /* LaunchScreen.storyboard */, 49 | 82EA08C5211B4857009CECE0 /* Localizable.strings */, 50 | ); 51 | name = SupportingFiles; 52 | path = SupportingFiels; 53 | sourceTree = ""; 54 | }; 55 | OBJ_10 /* Tests */ = { 56 | isa = PBXGroup; 57 | children = ( 58 | OBJ_11 /* Sources */, 59 | 82189635211B47B500CF5073 /* SupportingFiles */, 60 | ); 61 | name = Tests; 62 | sourceTree = SOURCE_ROOT; 63 | }; 64 | OBJ_11 /* Sources */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | OBJ_12 /* TestTests.swift */, 68 | OBJ_13 /* XCTestManifests.swift */, 69 | ); 70 | name = Sources; 71 | path = Tests/Sources; 72 | sourceTree = SOURCE_ROOT; 73 | }; 74 | OBJ_5 = { 75 | isa = PBXGroup; 76 | children = ( 77 | OBJ_6 /* Package.swift */, 78 | OBJ_7 /* App */, 79 | OBJ_10 /* Tests */, 80 | ); 81 | sourceTree = ""; 82 | }; 83 | OBJ_7 /* App */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | 82EA08C6211B48B7009CECE0 /* AppDelegate.swift */, 87 | OBJ_8 /* Sources */, 88 | 82EA08C0211B47FE009CECE0 /* SupportingFiles */, 89 | ); 90 | name = App; 91 | sourceTree = SOURCE_ROOT; 92 | }; 93 | OBJ_8 /* Sources */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | OBJ_9 /* TestA.swift */, 97 | 82189633211B47A600CF5073 /* TestB.swift */, 98 | 82EA08C3211B483C009CECE0 /* TestA.xib */, 99 | 82EA08C4211B4844009CECE0 /* TestB.xib */, 100 | 82EA08C7211B4A82009CECE0 /* XyzController.swift */, 101 | ); 102 | name = Sources; 103 | path = Sources/Sources; 104 | sourceTree = SOURCE_ROOT; 105 | }; 106 | /* End PBXGroup section */ 107 | 108 | /* Begin PBXProject section */ 109 | OBJ_1 /* Project object */ = { 110 | isa = PBXProject; 111 | attributes = { 112 | LastUpgradeCheck = 9999; 113 | }; 114 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "Test" */; 115 | compatibilityVersion = "Xcode 3.2"; 116 | developmentRegion = English; 117 | hasScannedForEncodings = 0; 118 | knownRegions = ( 119 | en, 120 | ); 121 | mainGroup = OBJ_5; 122 | productRefGroup = OBJ_5; 123 | projectDirPath = ""; 124 | projectRoot = ""; 125 | targets = ( 126 | ); 127 | }; 128 | /* End PBXProject section */ 129 | 130 | /* Begin XCBuildConfiguration section */ 131 | OBJ_3 /* Debug */ = { 132 | isa = XCBuildConfiguration; 133 | buildSettings = { 134 | CLANG_ENABLE_OBJC_ARC = YES; 135 | COMBINE_HIDPI_IMAGES = YES; 136 | COPY_PHASE_STRIP = NO; 137 | DEBUG_INFORMATION_FORMAT = dwarf; 138 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 139 | ENABLE_NS_ASSERTIONS = YES; 140 | GCC_OPTIMIZATION_LEVEL = 0; 141 | MACOSX_DEPLOYMENT_TARGET = 10.10; 142 | ONLY_ACTIVE_ARCH = YES; 143 | OTHER_SWIFT_FLAGS = "-DXcode"; 144 | PRODUCT_NAME = "$(TARGET_NAME)"; 145 | SDKROOT = macosx; 146 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 147 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 148 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 149 | USE_HEADERMAP = NO; 150 | }; 151 | name = Debug; 152 | }; 153 | OBJ_4 /* Release */ = { 154 | isa = XCBuildConfiguration; 155 | buildSettings = { 156 | CLANG_ENABLE_OBJC_ARC = YES; 157 | COMBINE_HIDPI_IMAGES = YES; 158 | COPY_PHASE_STRIP = YES; 159 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 160 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 161 | GCC_OPTIMIZATION_LEVEL = s; 162 | MACOSX_DEPLOYMENT_TARGET = 10.10; 163 | OTHER_SWIFT_FLAGS = "-DXcode"; 164 | PRODUCT_NAME = "$(TARGET_NAME)"; 165 | SDKROOT = macosx; 166 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 167 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 168 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 169 | USE_HEADERMAP = NO; 170 | }; 171 | name = Release; 172 | }; 173 | /* End XCBuildConfiguration section */ 174 | 175 | /* Begin XCConfigurationList section */ 176 | OBJ_2 /* Build configuration list for PBXProject "Test" */ = { 177 | isa = XCConfigurationList; 178 | buildConfigurations = ( 179 | OBJ_3 /* Debug */, 180 | OBJ_4 /* Release */, 181 | ); 182 | defaultConfigurationIsVisible = 0; 183 | defaultConfigurationName = Release; 184 | }; 185 | /* End XCConfigurationList section */ 186 | }; 187 | rootObject = OBJ_1 /* Project object */; 188 | } 189 | """ 190 | ) 191 | 192 | func testWithAllOptions() { 193 | resourcesLoaded([xcprojResource]) { 194 | let optionsDict: [String: Any] = [ 195 | "project_path": XcodeProjectNavigatorRuleTests.xcprojPath, 196 | "sorted": [], 197 | "inner_group_order": [ 198 | ["others", "plists", "entitlements"], 199 | ["code_files", "interfaces"], 200 | "assets", 201 | ["strings", "folders"] 202 | ], 203 | "structure": [ 204 | "Package.swift", 205 | ["App": [ 206 | "AppDelegate.swift" 207 | ]], 208 | ["Tests": [ 209 | ["SupportingFiles": [ 210 | "Info.plist" 211 | ]] 212 | ]] 213 | ] 214 | ] 215 | let rule = XcodeProjectNavigatorRule(optionsDict) 216 | 217 | let violations = rule.violations(in: Resource.baseUrl) 218 | XCTAssertEqual(violations.count, 0) 219 | } 220 | 221 | resourcesLoaded([xcprojResource]) { 222 | let optionsDict: [String: Any] = [ 223 | "project_path": XcodeProjectNavigatorRuleTests.xcprojPath, 224 | "sorted": [ 225 | "App", 226 | "Tests" 227 | ], 228 | "inner_group_order": [ 229 | ["interfaces", "code_files"], 230 | "assets", 231 | ["strings", "others"], 232 | "folders" 233 | ], 234 | "structure": [ 235 | "Package.swift", 236 | ["App": [ 237 | "AppDelegate.swift", 238 | "Resources" 239 | ]], 240 | ["Tests": [ 241 | ["SupportingFiles": [ 242 | "Info.plist" 243 | ]] 244 | ]], 245 | ["UITests": [ 246 | ["SupportingFiles": [ 247 | "Info.plist" 248 | ]] 249 | ]] 250 | ] 251 | ] 252 | let rule = XcodeProjectNavigatorRule(optionsDict) 253 | 254 | let violations = rule.violations(in: Resource.baseUrl) 255 | XCTAssertEqual(violations.count, 5) 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /beak.swift: -------------------------------------------------------------------------------- 1 | // beak: kareman/SwiftShell @ .upToNextMajor(from: "4.1.2") 2 | // beak: stencilproject/Stencil @ .upToNextMajor(from: "0.13.1") 3 | 4 | import Foundation 5 | import SwiftShell 6 | import Stencil 7 | 8 | /// Generates the LinuxMain.swift file by automatically searching the Tests path for tests. 9 | public func generateLinuxMain() { 10 | run(bash: "sourcery --sources Tests --templates .sourcery/LinuxMain.stencil --output .sourcery --force-parse generated") 11 | run(bash: "mv .sourcery/LinuxMain.generated.swift Tests/LinuxMain.swift") 12 | } 13 | 14 | /// Generates the RuleFactory.swift file by automatically searching the Rules path for rules. 15 | public func generateRuleFactory() { 16 | run(bash: "sourcery --sources Sources/ProjLintKit/Rules --templates .sourcery/RuleFactory.stencil --output .sourcery --force-parse generated") 17 | run(bash: "mv .sourcery/RuleFactory.generated.swift Sources/ProjLintKit/Globals/RuleFactory.swift") 18 | } 19 | 20 | /// Generates stubbed files for a new rule with options and tests for the rule and options and recreates the Xcode project. 21 | public func generateRule(identifier: String) throws { 22 | let loader = FileSystemLoader(paths: [".templates/"]) 23 | let env = Environment(loader: loader) 24 | 25 | let ruleTemplate = try env.loadTemplate(name: "Rule.stencil") 26 | let optionsTemplate = try env.loadTemplate(name: "Options.stencil") 27 | let ruleTestsTemplate = try env.loadTemplate(name: "RuleTests.stencil") 28 | let optionsTestsTemplate = try env.loadTemplate(name: "OptionsTests.stencil") 29 | 30 | let capitalizedComponents = identifier.components(separatedBy: "_").map { $0.capitalized } 31 | let name = capitalizedComponents.joined(separator: " ") 32 | let typePrefix = capitalizedComponents.joined() 33 | 34 | let dictionary = ["identifier": identifier, "name": name, "typePrefix": typePrefix] 35 | 36 | let ruleContents = try ruleTemplate.render(dictionary) 37 | let optionsContents = try optionsTemplate.render(dictionary) 38 | let ruleTestsContents = try ruleTestsTemplate.render(dictionary) 39 | let optionsTestsContents = try optionsTestsTemplate.render(dictionary) 40 | 41 | let pathContentsToWrite = [ 42 | "Sources/ProjLintKit/Rules/\(typePrefix)Rule.swift": ruleContents, 43 | "Sources/ProjLintKit/Rules/\(typePrefix)Options.swift": optionsContents, 44 | "Tests/ProjLintKitTests/Rules/\(typePrefix)RuleTests.swift": ruleTestsContents, 45 | "Tests/ProjLintKitTests/Rules/\(typePrefix)OptionsTests.swift": optionsTestsContents 46 | ] 47 | 48 | if let existingFilePath = pathContentsToWrite.keys.first(where: { FileManager.default.fileExists(atPath: $0) }) { 49 | print("Failed: File at path '\(existingFilePath)' already exists.") 50 | return 51 | } 52 | 53 | for (path, contents) in pathContentsToWrite { 54 | guard FileManager.default.createFile(atPath: path, contents: contents.data(using: .utf8), attributes: nil) else { 55 | print("Failed: Could not create file at path '\(path)'.") 56 | return 57 | } 58 | } 59 | 60 | generateRuleFactory() 61 | 62 | run(bash: "swift package generate-xcodeproj") 63 | } 64 | --------------------------------------------------------------------------------