mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
b83e0991b9
The MIT license doesn't require that all files be prepended with this licensing or copyright information. Realm confirmed that they're ok with this change. This will enable some companies to contribute to SwiftLint and the date & authorship information will remain accessible via git source control.
320 lines
12 KiB
Swift
320 lines
12 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
|
|
private enum AttributesRuleError: Error {
|
|
case unexpectedBlankLine
|
|
case moreThanOneAttributeInSameLine
|
|
}
|
|
|
|
public struct AttributesRule: ASTRule, OptInRule, ConfigurationProviderRule {
|
|
public var configuration = AttributesConfiguration()
|
|
|
|
private static let parametersPattern = "^\\s*\\(.+\\)"
|
|
private static let regularExpression = regex(parametersPattern, options: [])
|
|
|
|
public init() {}
|
|
|
|
public static let description = RuleDescription(
|
|
identifier: "attributes",
|
|
name: "Attributes",
|
|
description: "Attributes should be on their own lines in functions and types, " +
|
|
"but on the same line as variables and imports.",
|
|
kind: .style,
|
|
nonTriggeringExamples: AttributesRuleExamples.nonTriggeringExamples,
|
|
triggeringExamples: AttributesRuleExamples.triggeringExamples
|
|
)
|
|
|
|
public func validate(file: File) -> [StyleViolation] {
|
|
return validateTestableImport(file: file) +
|
|
validate(file: file, dictionary: file.structure.dictionary)
|
|
}
|
|
|
|
public func validate(file: File, kind: SwiftDeclarationKind,
|
|
dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] {
|
|
|
|
let attributeShouldBeOnSameLine: Bool?
|
|
if SwiftDeclarationKind.variableKinds.contains(kind) {
|
|
attributeShouldBeOnSameLine = true
|
|
} else if SwiftDeclarationKind.typeKinds.contains(kind) {
|
|
attributeShouldBeOnSameLine = false
|
|
} else if SwiftDeclarationKind.functionKinds.contains(kind) {
|
|
attributeShouldBeOnSameLine = false
|
|
} else {
|
|
attributeShouldBeOnSameLine = nil
|
|
}
|
|
|
|
if let attributeShouldBeOnSameLine = attributeShouldBeOnSameLine {
|
|
return validateKind(file: file,
|
|
attributeShouldBeOnSameLine: attributeShouldBeOnSameLine,
|
|
dictionary: dictionary)
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
private func validateTestableImport(file: File) -> [StyleViolation] {
|
|
let pattern = "@testable[\n]+\\s*import"
|
|
return file.match(pattern: pattern).compactMap { range, kinds -> StyleViolation? in
|
|
guard kinds == [.attributeBuiltin, .keyword] else {
|
|
return nil
|
|
}
|
|
|
|
let contents = file.contents.bridge()
|
|
let match = contents.substring(with: range)
|
|
let idx = match.lastIndex(of: "import") ?? 0
|
|
let location = idx + range.location
|
|
|
|
return StyleViolation(ruleDescription: type(of: self).description,
|
|
severity: configuration.severityConfiguration.severity,
|
|
location: Location(file: file, characterOffset: location))
|
|
}
|
|
}
|
|
|
|
private func validateKind(file: File,
|
|
attributeShouldBeOnSameLine: Bool,
|
|
dictionary: [String: SourceKitRepresentable]) -> [StyleViolation] {
|
|
let attributes = parseAttributes(dictionary: dictionary)
|
|
|
|
guard !attributes.isEmpty,
|
|
let offset = dictionary.offset,
|
|
let (line, _) = file.contents.bridge().lineAndCharacter(forByteOffset: offset) else {
|
|
return []
|
|
}
|
|
|
|
guard isViolation(lineNumber: line, file: file,
|
|
attributeShouldBeOnSameLine: attributeShouldBeOnSameLine) else {
|
|
return []
|
|
}
|
|
|
|
// Violation found!
|
|
return violation(dictionary: dictionary, file: file)
|
|
}
|
|
|
|
private func isViolation(lineNumber: Int, file: File,
|
|
attributeShouldBeOnSameLine: Bool) -> Bool {
|
|
let line = file.lines[lineNumber - 1]
|
|
|
|
let tokens = file.syntaxMap.tokens(inByteRange: line.byteRange)
|
|
let attributesTokensWithRanges = tokens.compactMap { attributeName(token: $0, file: file) }
|
|
|
|
let attributesTokens = Set(attributesTokensWithRanges.map { $0.0 })
|
|
|
|
do {
|
|
let previousAttributesWithParameters = try attributesFromPreviousLines(lineNumber: lineNumber - 1,
|
|
file: file)
|
|
let previousAttributes = Set(previousAttributesWithParameters.map { $0.0 })
|
|
|
|
if previousAttributes.isEmpty && attributesTokens.isEmpty {
|
|
return false
|
|
}
|
|
|
|
let alwaysOnSameLineAttributes = configuration.alwaysOnSameLine
|
|
let alwaysOnNewLineAttributes =
|
|
createAlwaysOnNewLineAttributes(previousAttributes: previousAttributesWithParameters,
|
|
attributesTokens: attributesTokensWithRanges,
|
|
line: line, file: file)
|
|
|
|
guard attributesTokens.isDisjoint(with: alwaysOnNewLineAttributes) &&
|
|
previousAttributes.isDisjoint(with: alwaysOnSameLineAttributes) else {
|
|
return true
|
|
}
|
|
|
|
// ignore whitelisted attributes
|
|
let attributesAfterWhitelist: Set<String>
|
|
let newLineExceptions = previousAttributes.intersection(alwaysOnNewLineAttributes)
|
|
let sameLineExceptions = attributesTokens.intersection(alwaysOnSameLineAttributes)
|
|
|
|
if attributeShouldBeOnSameLine {
|
|
attributesAfterWhitelist = attributesTokens
|
|
.union(newLineExceptions).union(sameLineExceptions)
|
|
} else {
|
|
attributesAfterWhitelist = attributesTokens
|
|
.subtracting(newLineExceptions).subtracting(sameLineExceptions)
|
|
}
|
|
|
|
return attributesAfterWhitelist.isEmpty == attributeShouldBeOnSameLine
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func createAlwaysOnNewLineAttributes(previousAttributes: [(String, Bool)],
|
|
attributesTokens: [(String, NSRange)],
|
|
line: Line, file: File) -> Set<String> {
|
|
let attributesTokensWithParameters: [(String, Bool)] = attributesTokens.map {
|
|
let hasParameter = attributeContainsParameter(attributeRange: $1,
|
|
line: line, file: file)
|
|
return ($0, hasParameter)
|
|
}
|
|
let allAttributes = previousAttributes + attributesTokensWithParameters
|
|
|
|
return Set(allAttributes.compactMap { token, hasParameter -> String? in
|
|
// an attribute should be on a new line if one of these is true:
|
|
// 1. it's a parameterized attribute
|
|
// a. the parameter is on the token (i.e. warn_unused_result)
|
|
// b. the parameter was parsed in the `hasParameter` variable (most attributes)
|
|
// 2. it's a whitelisted attribute, according to the current configuration
|
|
let isParameterized = hasParameter || token.bridge().contains("(")
|
|
if isParameterized || configuration.alwaysOnNewLine.contains(token) {
|
|
return token
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
private func violation(dictionary: [String: SourceKitRepresentable],
|
|
file: File) -> [StyleViolation] {
|
|
let location: Location
|
|
if let offset = dictionary.offset {
|
|
location = Location(file: file, byteOffset: offset)
|
|
} else {
|
|
location = Location(file: file.path)
|
|
}
|
|
|
|
return [
|
|
StyleViolation(ruleDescription: type(of: self).description,
|
|
severity: configuration.severityConfiguration.severity,
|
|
location: location)
|
|
]
|
|
}
|
|
|
|
// returns an array with the token itself (i.e. "@objc") and whether it's parameterized
|
|
// note: the parameter is not contained in the token
|
|
private func attributesFromPreviousLines(lineNumber: Int,
|
|
file: File) throws -> [(String, Bool)] {
|
|
var currentLine = lineNumber - 1
|
|
var allTokens = [(String, Bool)]()
|
|
var foundEmptyLine = false
|
|
let contents = file.contents.bridge()
|
|
|
|
while currentLine >= 0 {
|
|
defer {
|
|
currentLine -= 1
|
|
}
|
|
|
|
let line = file.lines[currentLine]
|
|
let tokens = file.syntaxMap.tokens(inByteRange: line.byteRange)
|
|
|
|
if tokens.isEmpty {
|
|
foundEmptyLine = true
|
|
continue
|
|
}
|
|
|
|
// check if it's a line with other declaration which could have its own attributes
|
|
let nonAttributeTokens = tokens.filter { token in
|
|
guard SyntaxKind(rawValue: token.type) == .keyword,
|
|
let keyword = contents.substringWithByteRange(start: token.offset,
|
|
length: token.length) else {
|
|
return false
|
|
}
|
|
|
|
return ["func", "var", "let"].contains(keyword)
|
|
}
|
|
|
|
guard nonAttributeTokens.isEmpty else {
|
|
break
|
|
}
|
|
|
|
let attributesTokens = tokens.compactMap { attributeName(token: $0, file: file) }
|
|
guard let firstTokenRange = attributesTokens.first?.1 else {
|
|
// found a line that does not contain an attribute token - we can stop looking
|
|
break
|
|
}
|
|
|
|
if attributesTokens.count > 1 {
|
|
// we don't allow multiple attributes in the same line if it's a previous line
|
|
throw AttributesRuleError.moreThanOneAttributeInSameLine
|
|
}
|
|
|
|
if foundEmptyLine {
|
|
// we don't allow attributes with empty lines between them
|
|
throw AttributesRuleError.unexpectedBlankLine
|
|
}
|
|
|
|
let hasParameter = attributeContainsParameter(attributeRange: firstTokenRange,
|
|
line: line, file: file)
|
|
|
|
allTokens.insert(contentsOf: attributesTokens.map { ($0.0, hasParameter) }, at: 0)
|
|
}
|
|
|
|
return allTokens
|
|
}
|
|
|
|
private func attributeContainsParameter(attributeRange: NSRange,
|
|
line: Line, file: File) -> Bool {
|
|
let restOfLineOffset = attributeRange.location + attributeRange.length
|
|
let restOfLineLength = line.byteRange.location + line.byteRange.length - restOfLineOffset
|
|
|
|
let regex = AttributesRule.regularExpression
|
|
let contents = file.contents.bridge()
|
|
|
|
// check if after the token is a `(` with only spaces allowed between the token and `(`
|
|
guard let restOfLine = contents.substringWithByteRange(start: restOfLineOffset, length: restOfLineLength),
|
|
case let range = NSRange(location: 0, length: restOfLine.bridge().length),
|
|
regex.firstMatch(in: restOfLine, options: [], range: range) != nil else {
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func attributeName(token: SyntaxToken, file: File) -> (String, NSRange)? {
|
|
guard SyntaxKind(rawValue: token.type) == .attributeBuiltin else {
|
|
return nil
|
|
}
|
|
|
|
let maybeName = file.contents.bridge().substringWithByteRange(start: token.offset,
|
|
length: token.length)
|
|
if let name = maybeName, isAttribute(name) {
|
|
return (name, NSRange(location: token.offset, length: token.length))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func isAttribute(_ name: String) -> Bool {
|
|
// all attributes *should* start with @
|
|
if name.hasPrefix("@") {
|
|
return true
|
|
}
|
|
|
|
// for some reason, `@` is not included if @warn_unused_result has parameters
|
|
if name.hasPrefix("warn_unused_result(") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func parseAttributes(dictionary: [String: SourceKitRepresentable]) -> [SwiftDeclarationAttributeKind] {
|
|
let attributes = dictionary.enclosedSwiftAttributes
|
|
let blacklist: Set<SwiftDeclarationAttributeKind> = [
|
|
.mutating,
|
|
.nonmutating,
|
|
.lazy,
|
|
.dynamic,
|
|
.final,
|
|
.infix,
|
|
.optional,
|
|
.override,
|
|
.postfix,
|
|
.prefix,
|
|
.required,
|
|
.weak,
|
|
.private,
|
|
.fileprivate,
|
|
.internal,
|
|
.public,
|
|
.open,
|
|
.setterPrivate,
|
|
.setterFilePrivate,
|
|
.setterInternal,
|
|
.setterPublic,
|
|
.setterOpen
|
|
]
|
|
return attributes.filter { !blacklist.contains($0) }
|
|
}
|
|
}
|