mirror of
https://github.com/realm/SwiftLint.git
synced 2026-05-07 20:12:49 +00:00
fa6bf50a22
A long-standing limitation with SourceKit's "editor open" request is
that we weren't able to get certain tokens, such as braces, brackets and
parentheses.
This meant that this code block would be counted as two lines:
```swift
print(
"hi"
)
```
because the trailing `)` would be treated as a whitespace line.
This meant that our "body length" family of rules that measure the
effective line count of declarations like functions, types or closures
would often significantly under-count the number of content lines in a
body.
Now with SwiftSyntax, we can get all tokens, including the ones
SourceKit was previously ignoring, so we can get much more accurate line
counts when ignoring whitespace and comments.
In addition, we weren't very thorough in how we measured body length.
As an exercise, how many lines long would you say the body of this
function is?
```swift
func hello() {
print("hello")
}
```
Does the body span one line or three lines?
I propose that we consistently ignore the left and right brace lines
when calculating the body line count of these scopes so that we measure
body line counts like this:
```swift
// 1 line
{ print("foo") }
// 1 line
{
}
// 1 line
{
print("foo")
}
// 2 lines
{
let sum = 1 + 2
print(sum)
}
```
Now with those changes in place, in order to keep the default
configuration thresholds to similar levels as before, we need to adjust
them slightly. Here's what I'm suggesting:
|Rule|Before|After|
|-|-|-|
|closure_body_length|20/100|30/100|
|function_body_length|40/100|50/100|
|type_body_length|200/350|250/350|
This is a pretty significant breaking change and I suspect we'll hear
from users who are surprised that some of their declarations now exceed
the rule limits, but I believe this new approach to calculating body
lines is more correct and intuitive compared to what we've had until
now.
OSSCheck is also going to report a bazillion changes with this, which is
expected given the scope of this change.
296 lines
12 KiB
Swift
296 lines
12 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
|
|
internal func regex(_ pattern: String,
|
|
options: NSRegularExpression.Options? = nil) -> NSRegularExpression {
|
|
// all patterns used for regular expressions in SwiftLint are string literals which have been
|
|
// confirmed to work, so it's ok to force-try here.
|
|
|
|
let options = options ?? [.anchorsMatchLines, .dotMatchesLineSeparators]
|
|
// swiftlint:disable:next force_try
|
|
return try! .cached(pattern: pattern, options: options)
|
|
}
|
|
|
|
extension SwiftLintFile {
|
|
internal func regions(restrictingRuleIdentifiers: Set<RuleIdentifier>? = nil) -> [Region] {
|
|
var regions = [Region]()
|
|
var disabledRules = Set<RuleIdentifier>()
|
|
let commands: [Command]
|
|
if let restrictingRuleIdentifiers = restrictingRuleIdentifiers {
|
|
commands = self.commands().filter { command in
|
|
return command.ruleIdentifiers.contains(where: restrictingRuleIdentifiers.contains)
|
|
}
|
|
} else {
|
|
commands = self.commands()
|
|
}
|
|
let commandPairs = zip(commands, Array(commands.dropFirst().map(Optional.init)) + [nil])
|
|
for (command, nextCommand) in commandPairs {
|
|
switch command.action {
|
|
case .disable:
|
|
disabledRules.formUnion(command.ruleIdentifiers)
|
|
|
|
case .enable:
|
|
disabledRules.subtract(command.ruleIdentifiers)
|
|
}
|
|
|
|
let start = Location(file: path, line: command.line, character: command.character)
|
|
let end = endOf(next: nextCommand)
|
|
guard start < end else { continue }
|
|
var didSetRegion = false
|
|
for (index, region) in zip(regions.indices, regions) where region.start == start && region.end == end {
|
|
regions[index] = Region(
|
|
start: start,
|
|
end: end,
|
|
disabledRuleIdentifiers: disabledRules.union(region.disabledRuleIdentifiers)
|
|
)
|
|
didSetRegion = true
|
|
}
|
|
if !didSetRegion {
|
|
regions.append(
|
|
Region(start: start, end: end, disabledRuleIdentifiers: disabledRules)
|
|
)
|
|
}
|
|
}
|
|
return regions
|
|
}
|
|
|
|
internal func commands(in range: NSRange? = nil) -> [Command] {
|
|
guard let range = range else {
|
|
return commands
|
|
.flatMap { $0.expand() }
|
|
}
|
|
|
|
let rangeStart = Location(file: self, characterOffset: range.location)
|
|
let rangeEnd = Location(file: self, characterOffset: NSMaxRange(range))
|
|
return commands
|
|
.filter { command in
|
|
let commandLocation = Location(file: path, line: command.line, character: command.character)
|
|
return rangeStart <= commandLocation && commandLocation <= rangeEnd
|
|
}
|
|
.flatMap { $0.expand() }
|
|
}
|
|
|
|
fileprivate func endOf(next command: Command?) -> Location {
|
|
guard let nextCommand = command else {
|
|
return Location(file: path, line: .max, character: .max)
|
|
}
|
|
let nextLine: Int
|
|
let nextCharacter: Int?
|
|
if let nextCommandCharacter = nextCommand.character {
|
|
nextLine = nextCommand.line
|
|
if nextCommandCharacter > 0 {
|
|
nextCharacter = nextCommandCharacter - 1
|
|
} else {
|
|
nextCharacter = nil
|
|
}
|
|
} else {
|
|
nextLine = max(nextCommand.line - 1, 0)
|
|
nextCharacter = .max
|
|
}
|
|
return Location(file: path, line: nextLine, character: nextCharacter)
|
|
}
|
|
|
|
internal func match(pattern: String, with syntaxKinds: [SyntaxKind], range: NSRange? = nil) -> [NSRange] {
|
|
return match(pattern: pattern, range: range)
|
|
.filter { $0.1 == syntaxKinds }
|
|
.map { $0.0 }
|
|
}
|
|
|
|
internal func matchesAndTokens(matching pattern: String,
|
|
range: NSRange? = nil) -> [(NSTextCheckingResult, [SwiftLintSyntaxToken])] {
|
|
let contents = stringView
|
|
let range = range ?? contents.range
|
|
let syntax = syntaxMap
|
|
return regex(pattern).matches(in: contents, options: [], range: range).compactMap { match in
|
|
let matchByteRange = contents.NSRangeToByteRange(start: match.range.location, length: match.range.length)
|
|
return matchByteRange.map { (match, syntax.tokens(inByteRange: $0)) }
|
|
}
|
|
}
|
|
|
|
internal func matchesAndSyntaxKinds(matching pattern: String,
|
|
range: NSRange? = nil) -> [(NSTextCheckingResult, [SyntaxKind])] {
|
|
return matchesAndTokens(matching: pattern, range: range).map { textCheckingResult, tokens in
|
|
(textCheckingResult, tokens.kinds)
|
|
}
|
|
}
|
|
|
|
internal func rangesAndTokens(matching pattern: String,
|
|
range: NSRange? = nil) -> [(NSRange, [SwiftLintSyntaxToken])] {
|
|
return matchesAndTokens(matching: pattern, range: range).map { ($0.0.range, $0.1) }
|
|
}
|
|
|
|
internal func match(pattern: String, range: NSRange? = nil, captureGroup: Int = 0) -> [(NSRange, [SyntaxKind])] {
|
|
return matchesAndSyntaxKinds(matching: pattern, range: range).map { textCheckingResult, syntaxKinds in
|
|
(textCheckingResult.range(at: captureGroup), syntaxKinds)
|
|
}
|
|
}
|
|
|
|
internal func swiftDeclarationKindsByLine() -> [[SwiftDeclarationKind]]? {
|
|
if sourcekitdFailed {
|
|
return nil
|
|
}
|
|
var results = [[SwiftDeclarationKind]](repeating: [], count: lines.count + 1)
|
|
var lineIterator = lines.makeIterator()
|
|
var structureIterator = structureDictionary.kinds().makeIterator()
|
|
var maybeLine = lineIterator.next()
|
|
var maybeStructure = structureIterator.next()
|
|
while let line = maybeLine, let structure = maybeStructure {
|
|
if line.byteRange.contains(structure.byteRange.location),
|
|
let swiftDeclarationKind = SwiftDeclarationKind(rawValue: structure.kind) {
|
|
results[line.index].append(swiftDeclarationKind)
|
|
}
|
|
if structure.byteRange.location >= line.byteRange.upperBound {
|
|
maybeLine = lineIterator.next()
|
|
} else {
|
|
maybeStructure = structureIterator.next()
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
internal func syntaxTokensByLine() -> [[SwiftLintSyntaxToken]]? {
|
|
if sourcekitdFailed {
|
|
return nil
|
|
}
|
|
var results = [[SwiftLintSyntaxToken]](repeating: [], count: lines.count + 1)
|
|
var tokenGenerator = syntaxMap.tokens.makeIterator()
|
|
var lineGenerator = lines.makeIterator()
|
|
var maybeLine = lineGenerator.next()
|
|
var maybeToken = tokenGenerator.next()
|
|
while let line = maybeLine, let token = maybeToken {
|
|
let tokenRange = token.range
|
|
if line.byteRange.contains(token.offset) ||
|
|
tokenRange.contains(line.byteRange.location) {
|
|
results[line.index].append(token)
|
|
}
|
|
let tokenEnd = tokenRange.upperBound
|
|
let lineEnd = line.byteRange.upperBound
|
|
if tokenEnd < lineEnd {
|
|
maybeToken = tokenGenerator.next()
|
|
} else if tokenEnd > lineEnd {
|
|
maybeLine = lineGenerator.next()
|
|
} else {
|
|
maybeLine = lineGenerator.next()
|
|
maybeToken = tokenGenerator.next()
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
internal func syntaxKindsByLine() -> [[SyntaxKind]]? {
|
|
guard !sourcekitdFailed, let tokens = syntaxTokensByLine() else {
|
|
return nil
|
|
}
|
|
|
|
return tokens.map { $0.kinds }
|
|
}
|
|
|
|
/**
|
|
This function returns only matches that are not contained in a syntax kind
|
|
specified.
|
|
|
|
- parameter pattern: regex pattern to be matched inside file.
|
|
- parameter excludingSyntaxKinds: syntax kinds the matches to be filtered
|
|
when inside them.
|
|
|
|
- returns: An array of [NSRange] objects consisting of regex matches inside
|
|
file contents.
|
|
*/
|
|
internal func match(pattern: String,
|
|
excludingSyntaxKinds syntaxKinds: Set<SyntaxKind>,
|
|
range: NSRange? = nil,
|
|
captureGroup: Int = 0) -> [NSRange] {
|
|
return match(pattern: pattern, range: range, captureGroup: captureGroup)
|
|
.filter { syntaxKinds.isDisjoint(with: $0.1) }
|
|
.map { $0.0 }
|
|
}
|
|
|
|
internal typealias MatchMapping = (NSTextCheckingResult) -> NSRange
|
|
|
|
internal func match(pattern: String,
|
|
range: NSRange? = nil,
|
|
excludingSyntaxKinds: Set<SyntaxKind>,
|
|
excludingPattern: String,
|
|
exclusionMapping: MatchMapping = { $0.range }) -> [NSRange] {
|
|
let matches = match(pattern: pattern, excludingSyntaxKinds: excludingSyntaxKinds)
|
|
if matches.isEmpty {
|
|
return []
|
|
}
|
|
let range = range ?? stringView.range
|
|
let exclusionRanges = regex(excludingPattern).matches(in: stringView, options: [],
|
|
range: range).map(exclusionMapping)
|
|
return matches.filter { !$0.intersects(exclusionRanges) }
|
|
}
|
|
|
|
internal func append(_ string: String) {
|
|
guard string.isNotEmpty else {
|
|
return
|
|
}
|
|
file.contents += string
|
|
if isVirtual {
|
|
return
|
|
}
|
|
guard let stringData = string.data(using: .utf8) else {
|
|
queuedFatalError("can't encode '\(string)' with UTF8")
|
|
}
|
|
guard let path = path, let fileHandle = FileHandle(forWritingAtPath: path) else {
|
|
queuedFatalError("can't write to path '\(String(describing: self.path))'")
|
|
}
|
|
_ = fileHandle.seekToEndOfFile()
|
|
fileHandle.write(stringData)
|
|
fileHandle.closeFile()
|
|
invalidateCache()
|
|
}
|
|
|
|
internal func write<S: StringProtocol>(_ string: S) {
|
|
guard string != contents else {
|
|
return
|
|
}
|
|
file.contents = String(string)
|
|
if isVirtual {
|
|
return
|
|
}
|
|
guard let path = path else {
|
|
queuedFatalError("file needs a path to call write(_:)")
|
|
}
|
|
guard let stringData = String(string).data(using: .utf8) else {
|
|
queuedFatalError("can't encode '\(string)' with UTF8")
|
|
}
|
|
do {
|
|
try stringData.write(to: URL(fileURLWithPath: path, isDirectory: false), options: .atomic)
|
|
} catch {
|
|
queuedFatalError("can't write file to \(path)")
|
|
}
|
|
invalidateCache()
|
|
}
|
|
|
|
internal func ruleEnabled(violatingRanges: [NSRange], for rule: Rule) -> [NSRange] {
|
|
let fileRegions = regions()
|
|
if fileRegions.isEmpty { return violatingRanges }
|
|
let violatingRanges = violatingRanges.filter { range in
|
|
let region = fileRegions.first {
|
|
$0.contains(Location(file: self, characterOffset: range.location))
|
|
}
|
|
return region?.isRuleEnabled(rule) ?? true
|
|
}
|
|
return violatingRanges
|
|
}
|
|
|
|
internal func ruleEnabled(violatingRange: NSRange, for rule: Rule) -> NSRange? {
|
|
return ruleEnabled(violatingRanges: [violatingRange], for: rule).first
|
|
}
|
|
|
|
internal func isACL(token: SwiftLintSyntaxToken) -> Bool {
|
|
guard token.kind == .attributeBuiltin else {
|
|
return false
|
|
}
|
|
|
|
let aclString = contents(for: token)
|
|
return aclString.flatMap(AccessControlLevel.init(description:)) != nil
|
|
}
|
|
|
|
internal func contents(for token: SwiftLintSyntaxToken) -> String? {
|
|
return stringView.substringWithByteRange(token.range)
|
|
}
|
|
}
|