The Philosophy of Linting, or: Is Consistency Foolish?
I did a deep-dive into SwiftLint last week and out of it, a general philosophy crystalized:
Lint Philosophy
・Enable all opt-in rules by default.
・Explicitely disable those you don’t want to follow.
・Prefer to tweak a rule configuration to taste before disabling it completely.
But before I explain, I need to address the elephant in the room, why lint in the first place, isn’t that usually just foolish consistency?
〜
Emerson’s well known quote on consistency reads:
“A foolish consistency is the hobgoblin of little minds, adored by little statesmen and philosophers and divines.”
Linting is all about consistency and I like consistency a lot. As I wrote in Xcode Sorted, I will sometimes go to great lengths to get the unsortable sorted and co-workers will attest that “can you sort this alpabetically?” is a common nit I will leave on a PR.
I also add SwiftLint as package plugin to all source targets in my SPM packages these days. Yes, even test targets.
.target(
name: "AssociatedObjects",
plugins: [
.plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
]
),
.testTarget(
name: "AssociatedObjectsTests",
dependencies: [
"AssociatedObjects",
],
plugins: [
.plugin(name: "SwiftLintPlugin", package: "SwiftLint"),
]
),
So, is Emerson saying that this linter-adorer has a small mind?
I should not think so and hope any self-reliant Swift developer will be convinced by reading the sentence that follows the quote, where Emerson eleborates:
“With consistency a great soul has simply nothing to do.”
But this is precisely the point! We lint our code so its consistency, its utter boringness, prevents our minds from being side-tracked by the formal, and to concern ourselves with more important things, like our shadows on the wall writing a type-erased TopLevelEncoder.
〜
With this out of the way: let’s go back to the linting philosophy.
Enable all opt-in rules by default.
This is what I recommend your .swiftlint.yml
file starts with:
opt_in_rules:
- all
As per the Readme about rule inclusion:
opt_in_rules
: Enable rules that are not part of the default set. The specialall
identifier will enable all opt in linter rules, except the ones listed indisabled_rules
.
Note that v0.50.3
, the current stable release, does not support this yet:
⚠️ 'all' is not a valid rule identifier
To use it, you’d have to wait for the next release or use a release candidate that includes it (or, roll your own, as I ended up doing to fix something unrelated).
Why am I recommending you do this? There are so many rules and you don’t like many of them.
-
The Rule Reference lists well over one hundred opt-in rules with often terse descriptions. Many of them I read and I have no idea wether I want to adopt it or not because no reasoning behind the rule is given. For example, “Prefer
private
overfileprivate
.” Uhm, yeah, but why? I don’t use it often, but when I do, it’s the most elegant solution to an access problem. Below are more examples for rules I ended up disabling and why. -
When new rules are added to the linter, you’ll automatically run into them when violations are reported without having to actively find out about them. After all, why would you go look for a rule you didn’t know you needed?
By opting in to all rules, you can experience concrete violations in the context of your own codebase which makes thinking about wether to follow them or not much more meaningful. This way, I often see obscure rules flagged that I end up following because they make sense but I would have never looked up and manually added as opt-in rule.
Explicitely disable those you don’t want to follow
Here’s the list of rules I ended up disabling in my codebase for now and why. That doesn’t mean I won’t disable others going forward, but I can do the thinking when I actually trigger a rule, not in the abstract by reading rule descriptions before I know they’re relevant to me.
disabled_rules:
- anonymous_argument_in_multiline_closure # Forces one to make up names unnecessarily.
- discouraged_optional_boolean # There are legitimate cases for this.
- discouraged_optional_collection # There are legitimate cases for this.
- explicit_acl # Making the implicit `internal` explicit only clutters declarations.
- explicit_enum_raw_value # Hmm, why?
- explicit_top_level_acl # Making the implicit `internal` explicit only clutters declarations.
- explicit_type_interface # Doesn't treat `if let foo /* = foo */` correctly, shouldn't have to specify a type for this case.
- extension_access_modifier # Then I always need to look back at the extension to see the access modifier.
- function_default_parameter_at_end # Prefer natural order of significance, wether there's a default or not.
- indentation_width # It's a good rule but it triggers incorrectly for `#if DEBUG` because Xcode doesn't indent those on `^I`.
- no_grouping_extension # Grouping helps visually spearate concerns.
- prefer_nimble # TODO: Port tests over to Nimble.
- required_deinit # Not sure what the point of an empty deinit is when there is nothing to deinitialize.
- strict_fileprivate # Hmm, why?
- type_contents_order # Like to group things by relatedness, have private properties after initialziers, etc.
- vertical_whitespace_opening_braces # I like to have an empty line before a doc comment.
This is from an actual linter config file of mine. I keep the reason for disabling a rule as a comment lest my thinking changes or circumstances are different in another project.
Prefer to tweak a rule configuration to taste before disabling it completely.
It’s easy to miss, but SwiftLint allows very fine-grained configuration for how many rules work and it’s often simple to tweak them just so. For example, missing_docs
by default uses: excludes_extensions: true, excludes_inherited_types: true, excludes_trivial_init: false
, while I have all three of them flipped in my configs:
missing_docs:
excludes_extensions: false
excludes_inherited_types: false
excludes_trivial_init: true
warning:
- open
- public
Also, remember you can (and should) disable violations in code with // swiftlint:disable
.
I do this for all force casts and force unwraps! I keep the rules enabled and let the linter remind me I shouldn’t really be doing it. I then think about it and put the disable comment when I have convinced myself about the validity.
Take the following example, I force-cast twice in one line, because this is a type-erasing wrapper and the wrapped encoder is an Any
. But it’s safe because the initializer constraints the types to exactly those I force-cast to (TopLevelEncoder
and Data
).
/// A type-erased data encoder.
public struct AnyDataEncoder {
/// Create a type-erased data encoder.
public init<Encoder: TopLevelEncoder>(encoder: Encoder) where Encoder.Output == Data {
self.encoder = encoder
}
/// TopLevelEncoder conformance where Output == Data
public func encode<T: Encodable>(_ value: T) throws -> Data {
return try (encoder as! any TopLevelEncoder).encode(value) as! Data // swiftlint:disable:this force_cast
}
/// callAsFunction conformance
public func callAsFunction<T: Encodable>(_ value: T) throws -> Data {
return try encode(value)
}
private var encoder: Any
}
〜
I haven’t actually written about the reason why I ended up on the deep dive into SwiftLint last week, but this essay is already long so I leave the details for another day. If you find this post because SwiftLint plugins do not work with nested configs, here’s the fix.
〜
Final thought on Emerson: With code, it is bad to be misunderstood. So, no, a foolish consistency in our craft is good, it is not a hobgoblin, but a virtue adored by great coders.