Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recursive Configs #301

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
12a4316
Added String extensions.
scottrhoyt Dec 27, 2015
8147a3e
Added recursive search for configurations.
scottrhoyt Dec 27, 2015
c8fc563
Use new String extension.
scottrhoyt Dec 27, 2015
cded5c9
Added `use_nested_configs` option for .swiftlint.yml (defaults to no).
scottrhoyt Dec 28, 2015
0bf09ef
Refactor `Configuration` initialization from yml for efficiency.
scottrhoyt Dec 28, 2015
d8c418e
Silently load nested configurations.
scottrhoyt Dec 28, 2015
a38abbb
Revert "Silently load nested configurations."
scottrhoyt Dec 28, 2015
27ccc06
Silence the logging inside of `Configuration.init` instead.
scottrhoyt Dec 28, 2015
aa5dd2c
Updated comments.
scottrhoyt Dec 29, 2015
6e336a5
Enable test coverage generation.
scottrhoyt Dec 29, 2015
599f3f1
Added isEqual for Rule and ParameterizedRule. Made Configuration and …
scottrhoyt Dec 30, 2015
17cecab
Moved == implementation for [Rule] to more appropriate spot. Wrote mi…
scottrhoyt Dec 30, 2015
44cb7a5
Extracted Configuration mocks to test class constants.
scottrhoyt Dec 30, 2015
277d693
Tested merge.
scottrhoyt Dec 30, 2015
a974317
Added Nested Configuration testing.
scottrhoyt Dec 30, 2015
87afad3
Replace manual config mocks with file-driven.
scottrhoyt Dec 30, 2015
f49003e
No longer needed RuleMocks at global scope.
scottrhoyt Dec 30, 2015
a523cf9
Cleaned up project mock *.yml's.
scottrhoyt Dec 31, 2015
2ef4701
Updated CHANGELOG
scottrhoyt Dec 31, 2015
37b462d
Update README and CHANGELOG
scottrhoyt Dec 31, 2015
089d89e
Merge branch 'realm/master' into sh-recursive-configs
scottrhoyt Dec 31, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
[JP Simard](https://github.com/jpsim)
[#222](https://github.com/realm/SwiftLint/issues/222)

* Add nested `.swiftlint.yml` configuration support.
[Scott Hoyt](https://github.com/scottrhoyt)
[#299](https://github.com/realm/SwiftLint/issues/299)

##### Bug Fixes

* Fix multibyte handling in many rules.
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ directory to see the currently implemented rules.
### Disable a rule in code

Rules can be disabled with a comment inside a source file with the following
format:
format:

`// swiftlint:disable <rule>`

Expand Down Expand Up @@ -146,6 +146,20 @@ type_body_length:
reporter: "csv" # reporter type (xcode, json, csv, checkstyle)
```

#### Nested Configurations

SwiftLint supports nesting configuration files for more granular control over
the linting process.

* Set the `use_nested_configs: true` value in your root `.swiftlint.yml` file
* Include additional `.swiftlint.yml` files where necessary in your directory
structure.
* Each file will be linted using the configuration file that is in it's
directory or at the deepest level of it's parent directories. Otherwise the
root configuration will be used.
* `excluded`, `included`, and `use_nested_configs` are ignored for nested
configurations

### Auto-correct

SwiftLint can automatically correct certain violations. Files on disk are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension NSFileManager {
}

public func filesToLintAtPath(path: String) -> [String] {
let absolutePath = (path.absolutePathRepresentation() as NSString).stringByStandardizingPath
let absolutePath = path.absolutePathStandardized()
var isDirectory: ObjCBool = false
guard fileExistsAtPath(absolutePath, isDirectory: &isDirectory) else {
return []
Expand Down
12 changes: 12 additions & 0 deletions Source/SwiftLintFramework/Extensions/String+SwiftLint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,16 @@ extension String {
}
return nil
}

var stringByDeletingLastPathComponent: String {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For methods on NSString, I prefer not masking them behind a Swift.String interface, since that hides the fact that the string has to be cast to NSString. Especially since SwiftLint (and SourceKitten) make heavy use of NSString in its implementation, you then end up casting an NSString into a Swift.String to use these methods, which then cast it back to an NSString.

For these reasons, I prefer casting at the call site.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That motivation makes a lot of sense to me. My thinking was mostly DRY, and how it would make it trivial to swap this out project-wide with a Swift String native approach if desired. But I'd be happy to revert.

return (self as NSString).stringByDeletingLastPathComponent
}

func stringByAppendingPathComponent(pathComponent: String) -> String {
return (self as NSString).stringByAppendingPathComponent(pathComponent)
}

public func absolutePathStandardized() -> String {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible that we never actually need absolutePathRepresentation, and that stringByStandardizingPath would be a sufficient replacement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly possible. I didn't do much testing other than realizing stringByStandardizingPath was necessary and following the pattern from here.

return (self.absolutePathRepresentation() as NSString).stringByStandardizingPath
}
}
95 changes: 83 additions & 12 deletions Source/SwiftLintFramework/Models/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ extension Yaml {
}
}

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

public var reporterFromString: Reporter.Type {
switch reporter {
Expand All @@ -47,10 +50,12 @@ public struct Configuration {
included: [String] = [],
excluded: [String] = [],
reporter: String = "xcode",
rules: [Rule] = Configuration.rulesFromYAML()) {
rules: [Rule] = Configuration.rulesFromYAML(),
useNestedConfigs: Bool = false) {
self.included = included
self.excluded = excluded
self.reporter = reporter
self.useNestedConfigs = useNestedConfigs

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

Expand Down Expand Up @@ -89,23 +94,36 @@ public struct Configuration {
}

public init?(yaml: String) {
let yamlResult = Yaml.load(yaml)
guard let yamlConfig = yamlResult.value else {
if let error = yamlResult.error {
queuedPrint(error)
}
guard let yamlConfig = Configuration.loadYaml(yaml) else {
return nil
}
self.init(yamlConfig: yamlConfig)
}

private init?(yamlConfig: Yaml) {
self.init(
disabledRules: yamlConfig["disabled_rules"].arrayOfStrings ?? [],
included: yamlConfig["included"].arrayOfStrings ?? [],
excluded: yamlConfig["excluded"].arrayOfStrings ?? [],
reporter: yamlConfig["reporter"].string ?? XcodeReporter.identifier,
rules: Configuration.rulesFromYAML(yamlConfig)
rules: Configuration.rulesFromYAML(yamlConfig),
useNestedConfigs: yamlConfig["use_nested_configs"].bool ?? false
)
}

public init(path: String = ".swiftlint.yml", optional: Bool = true) {
private static func loadYaml(yaml: String) -> Yaml? {
let yamlResult = Yaml.load(yaml)
if let yamlConfig = yamlResult.value {
return yamlConfig
} else {
if let error = yamlResult.error {
queuedPrint(error)
}
return nil
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^ This is the refactored init code. See this commit for the refactor in it's entirety.

public init(path: String = ".swiftlint.yml", optional: Bool = true, silent: Bool = false) {
let fullPath = (path as NSString).absolutePathRepresentation()
let failIfRequired = {
if !optional { fatalError("Could not read configuration file at path '\(fullPath)'") }
Expand All @@ -118,9 +136,12 @@ public struct Configuration {
do {
let yamlContents = try NSString(contentsOfFile: fullPath,
encoding: NSUTF8StringEncoding) as String
if let _ = Configuration(yaml: yamlContents) {
queuedPrintError("Loading configuration from '\(path)'")
self.init(yaml: yamlContents)!
if let yamlConfig = Configuration.loadYaml(yamlContents) {
if !silent {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 much better IMO

queuedPrintError("Loading configuration from '\(path)'")
}
self.init(yamlConfig: yamlConfig)!
configPath = fullPath
return
} else {
failIfRequired()
Expand Down Expand Up @@ -188,4 +209,54 @@ public struct Configuration {
let allPaths = self.lintablePathsForPath(path)
return allPaths.flatMap { File(path: $0) }
}

public func configForFile(file: File) -> Configuration {
if useNestedConfigs,
let containingDir = file.path?.stringByDeletingLastPathComponent {
return configForPath(containingDir)
}
return self
}
}

// MARK: - Nested Configurations Extension

public extension Configuration {
func configForPath(path: String) -> Configuration {
let configSearchPath = path.stringByAppendingPathComponent(".swiftlint.yml")

// If a config exists and it isn't us, load and merge the configs
if configSearchPath != configPath &&
NSFileManager.defaultManager().fileExistsAtPath(configSearchPath) {
return merge(Configuration(path: configSearchPath, optional: false, silent: true))
}

// If we are not at the root path, continue down the tree
if path != rootPath {
return configForPath(path.stringByDeletingLastPathComponent)
}

// If nothing else, return self
return self
}

// Currently merge simply overrides the current configuration with the new configuration.
// This requires that all config files be fully specified. In the future this will be changed
// to do a more intelligent merge allowing for partial nested configs.
func merge(config: Configuration) -> Configuration {
return config
}
}

// Mark - == Implementation

public func == (lhs: Configuration, rhs: Configuration) -> Bool {
return (lhs.disabledRules == rhs.disabledRules) &&
(lhs.excluded == rhs.excluded) &&
(lhs.included == rhs.included) &&
(lhs.reporter == rhs.reporter) &&
(lhs.useNestedConfigs == rhs.useNestedConfigs) &&
(lhs.configPath == rhs.configPath) &&
(lhs.rootPath == lhs.rootPath) &&
(lhs.rules == rhs.rules)
}
8 changes: 7 additions & 1 deletion Source/SwiftLintFramework/Models/RuleParameter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Copyright (c) 2015 Realm. All rights reserved.
//

public struct RuleParameter<T> {
public struct RuleParameter<T: Equatable> : Equatable {
public let severity: ViolationSeverity
public let value: T

Expand All @@ -15,3 +15,9 @@ public struct RuleParameter<T> {
self.value = value
}
}

// MARK: - Equatable

public func ==<T: Equatable>(lhs: RuleParameter<T>, rhs: RuleParameter<T>) -> Bool {
return lhs.value == rhs.value && lhs.severity == rhs.severity
}
25 changes: 24 additions & 1 deletion Source/SwiftLintFramework/Protocols/Rule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,35 @@ public protocol Rule {
func validateFile(file: File) -> [StyleViolation]
}

extension Rule {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not possible to make Rule conform to Equatable and implement ==? Would be a bit more Swifty than isEqualTo. Or ParameterizedRule for that matter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it would be possible. The problem is that making Rule conform to Equatable will impose a Self requirement on Rule, therefore you won't be able to use it as a type (e.g. [Rule]) and only as a generic constraint. This exact case is covered really well in the 2015 WWDC session on Protocol Orientated Programming, and implementing isEqualTo for situations like this was the recommended solution. Now, that still doesn't stop us from implementing == as well using isEqualTo, and I originally did that, but then took it out as because these sort of semantics should probably be discussed before implemented just because it's not 100% clear. In addition to the WWDC video, there's a pretty decent discussion of it here. I can definitely implement it with Equatable, but I'll have to modify all the cases that the code uses Rule as a type.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping as-is is fine then.

func isEqualTo(rule: Rule) -> Bool {
return self.dynamicType.description == rule.dynamicType.description
}
}

public protocol ParameterizedRule: Rule {
typealias ParameterType
typealias ParameterType: Equatable
init(parameters: [RuleParameter<ParameterType>])
var parameters: [RuleParameter<ParameterType>] { get }
}

extension ParameterizedRule {
func isEqualTo(rule: Self) -> Bool {
return (self.dynamicType.description == rule.dynamicType.description) &&
(self.parameters == rule.parameters)
}
}

public protocol CorrectableRule: Rule {
func correctFile(file: File) -> [Correction]
}

// MARK: - == Implementations

func == (lhs: [Rule], rhs: [Rule]) -> Bool {
if lhs.count == rhs.count {
return zip(lhs, rhs).map { $0.isEqualTo($1) }.reduce(true) { $0 && $1 }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can avoid creating the intermediate closures here:

zip(lhs, rhs).map(Rule.isEqualTo).reduce(true, &&)

}

return false
}
104 changes: 103 additions & 1 deletion Source/SwiftLintFrameworkTests/ConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
// Copyright © 2015 Realm. All rights reserved.
//

import SwiftLintFramework
@testable import SwiftLintFramework
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to make isEqualTo part of the public API, therefore unnecessary to import this as @testable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's probably ok. I get slightly nervous with equality stuff because it's easy for someone to add a member to the respective type without including it in the member-wise check. You can somewhat mitigate this by using Swift's limited reflection to write a test to assert that the number of members hasn't changed. But I often just keep this stuff out of public API until the data structures have stabilized or it's otherwise needed. Just let me know what you'd prefer.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping as-is is fine.

import SourceKittenFramework
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this import used anywhere? I can't see.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for File.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, 👍 then

import XCTest

class ConfigurationTests: XCTestCase {
Expand Down Expand Up @@ -89,4 +90,105 @@ class ConfigurationTests: XCTestCase {
let paths = configuration.lintablePathsForPath("", fileManager: TestFileManager())
XCTAssertEqual(["directory/File1.swift", "directory/File2.swift"], paths)
}

// MARK: - Testing Configuration Equality

private var projectMockConfig0: Configuration {
var config = Configuration(path: projectMockYAML0, optional: false, silent: true)
config.rootPath = projectMockPathLevel0
return config
}

private var projectMockConfig2: Configuration {
return Configuration(path: projectMockYAML2, optional: false, silent: true)
}

func testIsEqualTo() {
XCTAssertEqual(projectMockConfig0, projectMockConfig0)
}

func testIsNotEqualTo() {
XCTAssertNotEqual(projectMockConfig0, projectMockConfig2)
}

// MARK: - Testing Nested Configurations

func testMerge() {
XCTAssertEqual(projectMockConfig0.merge(projectMockConfig2), projectMockConfig2)
}

func testLevel0() {
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift0)!),
projectMockConfig0)
}

func testLevel1() {
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift1)!),
projectMockConfig0)
}

func testLevel2() {
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift2)!),
projectMockConfig0.merge(projectMockConfig2))
}

func testLevel3() {
XCTAssertEqual(projectMockConfig0.configForFile(File(path: projectMockSwift3)!),
projectMockConfig0.merge(projectMockConfig2))
}

func testDoNotUseNestedConfigs() {
var config = Configuration(yaml: "use_nested_configs: false\n")!
config.rootPath = projectMockPathLevel0
XCTAssertEqual(config.configForFile(File(path: projectMockSwift3)!),
config)
}
}

// MARK: - ProjectMock Paths

extension XCTestCase {
var bundlePath: String {
return NSBundle(forClass: self.dynamicType).resourcePath!
}

var projectMockPathLevel0: String {
return bundlePath.stringByAppendingPathComponent("ProjectMock")
}

var projectMockPathLevel1: String {
return projectMockPathLevel0.stringByAppendingPathComponent("Level1")
}

var projectMockPathLevel2: String {
return projectMockPathLevel1.stringByAppendingPathComponent("Level2")
}

var projectMockPathLevel3: String {
return projectMockPathLevel2.stringByAppendingPathComponent("Level3")
}

var projectMockYAML0: String {
return projectMockPathLevel0.stringByAppendingPathComponent(".swiftlint.yml")
}

var projectMockYAML2: String {
return projectMockPathLevel2.stringByAppendingPathComponent(".swiftlint.yml")
}

var projectMockSwift0: String {
return projectMockPathLevel0.stringByAppendingPathComponent("Level0.swift")
}

var projectMockSwift1: String {
return projectMockPathLevel1.stringByAppendingPathComponent("Level1.swift")
}

var projectMockSwift2: String {
return projectMockPathLevel2.stringByAppendingPathComponent("Level2.swift")
}

var projectMockSwift3: String {
return projectMockPathLevel3.stringByAppendingPathComponent("Level3.swift")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
disabled_rules:
- force_cast
included:
- "everything"
exluded:
- "the place where i committed many coding sins"
line_length: 10000000000
reporter: "json"
use_nested_configs: true
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// This is just a mock Swift file
Loading