Skip to content

Commit c6f464a

Browse files
committed
Merge pull request #307 from realm/jp-sh-recursive-configs
Recursive Configs
2 parents 029a5eb + 1e553a5 commit c6f464a

File tree

19 files changed

+387
-19
lines changed

19 files changed

+387
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
[JP Simard](https://github.com/jpsim)
1919
[#222](https://github.com/realm/SwiftLint/issues/222)
2020

21+
* Add nested `.swiftlint.yml` configuration support.
22+
[Scott Hoyt](https://github.com/scottrhoyt)
23+
[#299](https://github.com/realm/SwiftLint/issues/299)
24+
2125
##### Bug Fixes
2226

2327
* Fix multibyte handling in many rules.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ directory to see the currently implemented rules.
8080
### Disable a rule in code
8181

8282
Rules can be disabled with a comment inside a source file with the following
83-
format:
83+
format:
8484

8585
`// swiftlint:disable <rule>`
8686

@@ -146,6 +146,20 @@ type_body_length:
146146
reporter: "csv" # reporter type (xcode, json, csv, checkstyle)
147147
```
148148
149+
#### Nested Configurations
150+
151+
SwiftLint supports nesting configuration files for more granular control over
152+
the linting process.
153+
154+
* Set the `use_nested_configs: true` value in your root `.swiftlint.yml` file
155+
* Include additional `.swiftlint.yml` files where necessary in your directory
156+
structure.
157+
* Each file will be linted using the configuration file that is in its
158+
directory or at the deepest level of its parent directories. Otherwise the
159+
root configuration will be used.
160+
* `excluded`, `included`, and `use_nested_configs` are ignored for nested
161+
configurations.
162+
149163
### Auto-correct
150164

151165
SwiftLint can automatically correct certain violations. Files on disk are

Source/SwiftLintFramework/Extensions/NSFileManager+SwiftLint.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ extension NSFileManager {
1515
}
1616

1717
public func filesToLintAtPath(path: String) -> [String] {
18-
let absolutePath = (path.absolutePathRepresentation() as NSString).stringByStandardizingPath
18+
let absolutePath = path.absolutePathStandardized()
1919
var isDirectory: ObjCBool = false
2020
guard fileExistsAtPath(absolutePath, isDirectory: &isDirectory) else {
2121
return []

Source/SwiftLintFramework/Extensions/String+SwiftLint.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,8 @@ extension String {
6969
}
7070
return nil
7171
}
72+
73+
public func absolutePathStandardized() -> String {
74+
return (self.absolutePathRepresentation() as NSString).stringByStandardizingPath
75+
}
7276
}

Source/SwiftLintFramework/Models/Configuration.swift

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ extension Yaml {
2121
}
2222
}
2323

24-
public struct Configuration {
24+
public struct Configuration: Equatable {
2525
public let disabledRules: [String] // disabled_rules
2626
public let included: [String] // included
2727
public let excluded: [String] // excluded
2828
public let reporter: String // reporter (xcode, json, csv, checkstyle)
2929
public let rules: [Rule]
30+
public let useNestedConfigs: Bool // process nested configs, will default to false
31+
public var rootPath: String? // the root path of the lint to search for nested configs
32+
private var configPath: String? // if successfully load from a path
3033

3134
public var reporterFromString: Reporter.Type {
3235
switch reporter {
@@ -47,10 +50,12 @@ public struct Configuration {
4750
included: [String] = [],
4851
excluded: [String] = [],
4952
reporter: String = "xcode",
50-
rules: [Rule] = Configuration.rulesFromYAML()) {
53+
rules: [Rule] = Configuration.rulesFromYAML(),
54+
useNestedConfigs: Bool = false) {
5155
self.included = included
5256
self.excluded = excluded
5357
self.reporter = reporter
58+
self.useNestedConfigs = useNestedConfigs
5459

5560
// Validate that all rule identifiers map to a defined rule
5661

@@ -89,23 +94,35 @@ public struct Configuration {
8994
}
9095

9196
public init?(yaml: String) {
92-
let yamlResult = Yaml.load(yaml)
93-
guard let yamlConfig = yamlResult.value else {
94-
if let error = yamlResult.error {
95-
queuedPrint(error)
96-
}
97+
guard let yamlConfig = Configuration.loadYaml(yaml) else {
9798
return nil
9899
}
100+
self.init(yamlConfig: yamlConfig)
101+
}
102+
103+
private init?(yamlConfig: Yaml) {
99104
self.init(
100105
disabledRules: yamlConfig["disabled_rules"].arrayOfStrings ?? [],
101106
included: yamlConfig["included"].arrayOfStrings ?? [],
102107
excluded: yamlConfig["excluded"].arrayOfStrings ?? [],
103108
reporter: yamlConfig["reporter"].string ?? XcodeReporter.identifier,
104-
rules: Configuration.rulesFromYAML(yamlConfig)
109+
rules: Configuration.rulesFromYAML(yamlConfig),
110+
useNestedConfigs: yamlConfig["use_nested_configs"].bool ?? false
105111
)
106112
}
107113

108-
public init(path: String = ".swiftlint.yml", optional: Bool = true) {
114+
private static func loadYaml(yaml: String) -> Yaml? {
115+
let yamlResult = Yaml.load(yaml)
116+
if let yamlConfig = yamlResult.value {
117+
return yamlConfig
118+
}
119+
if let error = yamlResult.error {
120+
queuedPrint(error)
121+
}
122+
return nil
123+
}
124+
125+
public init(path: String = ".swiftlint.yml", optional: Bool = true, silent: Bool = false) {
109126
let fullPath = (path as NSString).absolutePathRepresentation()
110127
let failIfRequired = {
111128
if !optional { fatalError("Could not read configuration file at path '\(fullPath)'") }
@@ -118,9 +135,12 @@ public struct Configuration {
118135
do {
119136
let yamlContents = try NSString(contentsOfFile: fullPath,
120137
encoding: NSUTF8StringEncoding) as String
121-
if let _ = Configuration(yaml: yamlContents) {
122-
queuedPrintError("Loading configuration from '\(path)'")
123-
self.init(yaml: yamlContents)!
138+
if let yamlConfig = Configuration.loadYaml(yamlContents) {
139+
if !silent {
140+
queuedPrintError("Loading configuration from '\(path)'")
141+
}
142+
self.init(yamlConfig: yamlConfig)!
143+
configPath = fullPath
124144
return
125145
} else {
126146
failIfRequired()
@@ -188,4 +208,55 @@ public struct Configuration {
188208
let allPaths = self.lintablePathsForPath(path)
189209
return allPaths.flatMap { File(path: $0) }
190210
}
211+
212+
public func configForFile(file: File) -> Configuration {
213+
if useNestedConfigs,
214+
let containingDir = (file.path as NSString?)?.stringByDeletingLastPathComponent {
215+
return configForPath(containingDir)
216+
}
217+
return self
218+
}
219+
}
220+
221+
// MARK: - Nested Configurations Extension
222+
223+
public extension Configuration {
224+
func configForPath(path: String) -> Configuration {
225+
let path = path as NSString
226+
let configSearchPath = path.stringByAppendingPathComponent(".swiftlint.yml")
227+
228+
// If a config exists and it isn't us, load and merge the configs
229+
if configSearchPath != configPath &&
230+
NSFileManager.defaultManager().fileExistsAtPath(configSearchPath) {
231+
return merge(Configuration(path: configSearchPath, optional: false, silent: true))
232+
}
233+
234+
// If we are not at the root path, continue down the tree
235+
if path != rootPath {
236+
return configForPath(path.stringByDeletingLastPathComponent)
237+
}
238+
239+
// If nothing else, return self
240+
return self
241+
}
242+
243+
// Currently merge simply overrides the current configuration with the new configuration.
244+
// This requires that all config files be fully specified. In the future this will be changed
245+
// to do a more intelligent merge allowing for partial nested configs.
246+
func merge(config: Configuration) -> Configuration {
247+
return config
248+
}
249+
}
250+
251+
// Mark - == Implementation
252+
253+
public func == (lhs: Configuration, rhs: Configuration) -> Bool {
254+
return (lhs.disabledRules == rhs.disabledRules) &&
255+
(lhs.excluded == rhs.excluded) &&
256+
(lhs.included == rhs.included) &&
257+
(lhs.reporter == rhs.reporter) &&
258+
(lhs.useNestedConfigs == rhs.useNestedConfigs) &&
259+
(lhs.configPath == rhs.configPath) &&
260+
(lhs.rootPath == lhs.rootPath) &&
261+
(lhs.rules == rhs.rules)
191262
}

Source/SwiftLintFramework/Models/RuleParameter.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// Copyright (c) 2015 Realm. All rights reserved.
77
//
88

9-
public struct RuleParameter<T> {
9+
public struct RuleParameter<T: Equatable>: Equatable {
1010
public let severity: ViolationSeverity
1111
public let value: T
1212

@@ -15,3 +15,9 @@ public struct RuleParameter<T> {
1515
self.value = value
1616
}
1717
}
18+
19+
// MARK: - Equatable
20+
21+
public func ==<T: Equatable>(lhs: RuleParameter<T>, rhs: RuleParameter<T>) -> Bool {
22+
return lhs.value == rhs.value && lhs.severity == rhs.severity
23+
}

Source/SwiftLintFramework/Protocols/Rule.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,35 @@ public protocol Rule {
1313
func validateFile(file: File) -> [StyleViolation]
1414
}
1515

16+
extension Rule {
17+
func isEqualTo(rule: Rule) -> Bool {
18+
return self.dynamicType.description == rule.dynamicType.description
19+
}
20+
}
21+
1622
public protocol ParameterizedRule: Rule {
17-
typealias ParameterType
23+
typealias ParameterType: Equatable
1824
init(parameters: [RuleParameter<ParameterType>])
1925
var parameters: [RuleParameter<ParameterType>] { get }
2026
}
2127

28+
extension ParameterizedRule {
29+
func isEqualTo(rule: Self) -> Bool {
30+
return (self.dynamicType.description == rule.dynamicType.description) &&
31+
(self.parameters == rule.parameters)
32+
}
33+
}
34+
2235
public protocol CorrectableRule: Rule {
2336
func correctFile(file: File) -> [Correction]
2437
}
38+
39+
// MARK: - == Implementations
40+
41+
func == (lhs: [Rule], rhs: [Rule]) -> Bool {
42+
if lhs.count == rhs.count {
43+
return zip(lhs, rhs).map { $0.isEqualTo($1) }.reduce(true) { $0 && $1 }
44+
}
45+
46+
return false
47+
}

Source/SwiftLintFrameworkTests/ConfigurationTests.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//
88

99
import SwiftLintFramework
10+
import SourceKittenFramework
1011
import XCTest
1112

1213
class ConfigurationTests: XCTestCase {
@@ -89,4 +90,111 @@ class ConfigurationTests: XCTestCase {
8990
let paths = configuration.lintablePathsForPath("", fileManager: TestFileManager())
9091
XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"], paths)
9192
}
93+
94+
// MARK: - Testing Configuration Equality
95+
96+
private var projectMockConfig0: Configuration {
97+
var config = Configuration(path: projectMockYAML0, optional: false, silent: true)
98+
config.rootPath = projectMockPathLevel0
99+
return config
100+
}
101+
102+
private var projectMockConfig2: Configuration {
103+
return Configuration(path: projectMockYAML2, optional: false, silent: true)
104+
}
105+
106+
func testIsEqualTo() {
107+
XCTAssertEqual(projectMockConfig0, projectMockConfig0)
108+
}
109+
110+
func testIsNotEqualTo() {
111+
XCTAssertNotEqual(projectMockConfig0, projectMockConfig2)
112+
}
113+
114+
// MARK: - Testing Nested Configurations
115+
116+
func testMerge() {
117+
XCTAssertEqual(projectMockConfig0.merge(projectMockConfig2), projectMockConfig2)
118+
}
119+
120+
func testLevel0() {
121+
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift0)!),
122+
projectMockConfig0)
123+
}
124+
125+
func testLevel1() {
126+
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift1)!),
127+
projectMockConfig0)
128+
}
129+
130+
func testLevel2() {
131+
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift2)!),
132+
projectMockConfig0.merge(projectMockConfig2))
133+
}
134+
135+
func testLevel3() {
136+
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift3)!),
137+
projectMockConfig0.merge(projectMockConfig2))
138+
}
139+
140+
func testDoNotUseNestedConfigs() {
141+
var config = Configuration(yaml: "use_nested_configs: false\n")!
142+
config.rootPath = projectMockPathLevel0
143+
XCTAssertEqual(config.configForFile(File(path: projectMockSwift3)!),
144+
config)
145+
}
146+
}
147+
148+
// MARK: - ProjectMock Paths
149+
150+
extension String {
151+
func stringByAppendingPathComponent(pathComponent: String) -> String {
152+
return (self as NSString).stringByAppendingPathComponent(pathComponent)
153+
}
154+
}
155+
156+
extension XCTestCase {
157+
var bundlePath: String {
158+
return NSBundle(forClass: self.dynamicType).resourcePath!
159+
}
160+
161+
var projectMockPathLevel0: String {
162+
return bundlePath.stringByAppendingPathComponent("ProjectMock")
163+
}
164+
165+
var projectMockPathLevel1: String {
166+
return projectMockPathLevel0.stringByAppendingPathComponent("Level1")
167+
}
168+
169+
var projectMockPathLevel2: String {
170+
return projectMockPathLevel1.stringByAppendingPathComponent("Level2")
171+
}
172+
173+
var projectMockPathLevel3: String {
174+
return projectMockPathLevel2.stringByAppendingPathComponent("Level3")
175+
}
176+
177+
var projectMockYAML0: String {
178+
return projectMockPathLevel0.stringByAppendingPathComponent(".swiftlint.yml")
179+
}
180+
181+
var projectMockYAML2: String {
182+
return projectMockPathLevel2.stringByAppendingPathComponent(".swiftlint.yml")
183+
}
184+
185+
var projectMockSwift0: String {
186+
return projectMockPathLevel0.stringByAppendingPathComponent("Level0.swift")
187+
}
188+
189+
var projectMockSwift1: String {
190+
return projectMockPathLevel1.stringByAppendingPathComponent("Level1.swift")
191+
}
192+
193+
var projectMockSwift2: String {
194+
return projectMockPathLevel2.stringByAppendingPathComponent("Level2.swift")
195+
}
196+
197+
var projectMockSwift3: String {
198+
return projectMockPathLevel3.stringByAppendingPathComponent("Level3.swift")
199+
}
92200
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
disabled_rules:
2+
- force_cast
3+
included:
4+
- "everything"
5+
exluded:
6+
- "the place where i committed many coding sins"
7+
line_length: 10000000000
8+
reporter: "json"
9+
use_nested_configs: true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// This is just a mock Swift file

0 commit comments

Comments
 (0)