mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
415 lines
15 KiB
Swift
415 lines
15 KiB
Swift
//
|
|
// Linter.swift
|
|
// SwiftLint
|
|
//
|
|
// Created by JP Simard on 2015-05-16.
|
|
// Copyright (c) 2015 Realm. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftXPC
|
|
import SourceKittenFramework
|
|
|
|
public enum StyleViolationType: String, Printable {
|
|
case NameFormat = "Name Format"
|
|
case Length = "Length"
|
|
case TrailingNewline = "Trailing Newline"
|
|
case LeadingWhitespace = "Leading Whitespace"
|
|
case TrailingWhitespace = "Trailing Whitespace"
|
|
case ForceCast = "Force Cast"
|
|
|
|
public var description: String { return rawValue }
|
|
}
|
|
|
|
public struct Location: Printable, Equatable {
|
|
public let file: String?
|
|
public let line: Int?
|
|
public let character: Int?
|
|
public var description: String {
|
|
// Xcode likes warnings and errors in the following format:
|
|
// {full_path_to_file}{:line}{:character}: {error,warning}: {content}
|
|
return (file ?? "<nopath>") +
|
|
(map(line, { ":\($0)" }) ?? "") +
|
|
(map(character, { ":\($0)" }) ?? "")
|
|
}
|
|
|
|
public init(file: String?, line: Int? = nil, character: Int? = nil) {
|
|
self.file = file
|
|
self.line = line
|
|
self.character = character
|
|
}
|
|
|
|
public init(file: File, offset: Int) {
|
|
self.file = file.path
|
|
if let lineAndCharacter = file.contents.lineAndCharacterForByteOffset(offset) {
|
|
line = lineAndCharacter.line
|
|
character = nil // FIXME: Use lineAndCharacter.character once it works.
|
|
} else {
|
|
line = nil
|
|
character = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Equatable
|
|
|
|
/**
|
|
Returns true if `lhs` Location is equal to `rhs` Location.
|
|
|
|
:param: lhs Location to compare to `rhs`.
|
|
:param: rhs Location to compare to `lhs`.
|
|
|
|
:returns: True if `lhs` Location is equal to `rhs` Location.
|
|
*/
|
|
public func ==(lhs: Location, rhs: Location) -> Bool {
|
|
return lhs.file == rhs.file &&
|
|
lhs.line == rhs.line &&
|
|
lhs.character == rhs.character
|
|
}
|
|
|
|
public enum ViolationSeverity: Int, Printable, Comparable {
|
|
case VeryLow
|
|
case Low
|
|
case Medium
|
|
case High
|
|
case VeryHigh
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .VeryLow:
|
|
return "Very Low"
|
|
case .Low:
|
|
return "Low"
|
|
case .Medium:
|
|
return "Medium"
|
|
case .High:
|
|
return "High"
|
|
case .VeryHigh:
|
|
return "Very High"
|
|
}
|
|
}
|
|
|
|
public var xcodeSeverityDescription: String {
|
|
return self <= Medium ? "warning" : "error"
|
|
}
|
|
}
|
|
|
|
// MARK: Comparable
|
|
|
|
public func == (lhs: ViolationSeverity, rhs: ViolationSeverity) -> Bool {
|
|
return lhs.rawValue == rhs.rawValue
|
|
}
|
|
public func < (lhs: ViolationSeverity, rhs: ViolationSeverity) -> Bool {
|
|
return lhs.rawValue < rhs.rawValue
|
|
}
|
|
|
|
public struct StyleViolation: Printable, Equatable {
|
|
public let type: StyleViolationType
|
|
public let severity: ViolationSeverity
|
|
public let location: Location
|
|
public let reason: String?
|
|
public var description: String {
|
|
// {full_path_to_file}{:line}{:character}: {error,warning}: {content}
|
|
return "\(location): " +
|
|
"\(severity.xcodeSeverityDescription): " +
|
|
"\(type) Violation (\(severity) Severity): " +
|
|
(reason ?? "")
|
|
}
|
|
|
|
public init(type: StyleViolationType, location: Location, reason: String? = nil) {
|
|
severity = .Low
|
|
self.type = type
|
|
self.location = location
|
|
self.reason = reason
|
|
}
|
|
}
|
|
|
|
// MARK: Equatable
|
|
|
|
/**
|
|
Returns true if `lhs` StyleViolation is equal to `rhs` StyleViolation.
|
|
|
|
:param: lhs StyleViolation to compare to `rhs`.
|
|
:param: rhs StyleViolation to compare to `lhs`.
|
|
|
|
:returns: True if `lhs` StyleViolation is equal to `rhs` StyleViolation.
|
|
*/
|
|
public func ==(lhs: StyleViolation, rhs: StyleViolation) -> Bool {
|
|
return lhs.type == rhs.type &&
|
|
lhs.location == rhs.location &&
|
|
lhs.reason == rhs.reason
|
|
}
|
|
|
|
typealias Line = (index: Int, content: String)
|
|
|
|
// Violation Extensions
|
|
extension File {
|
|
func lineLengthViolations(lines: [Line]) -> [StyleViolation] {
|
|
return lines.filter({ count($0.content) > 100 }).map {
|
|
return StyleViolation(type: .Length,
|
|
location: Location(file: self.path, line: $0.index),
|
|
reason: "Line #\($0.index) should be 100 characters or less: " +
|
|
"currently \(count($0.content)) characters")
|
|
}
|
|
}
|
|
|
|
func leadingWhitespaceViolations(contents: String) -> [StyleViolation] {
|
|
let countOfLeadingWhitespace = contents.countOfLeadingCharactersInSet(
|
|
NSCharacterSet.whitespaceAndNewlineCharacterSet()
|
|
)
|
|
if countOfLeadingWhitespace != 0 {
|
|
return [StyleViolation(type: .LeadingWhitespace,
|
|
location: Location(file: self.path, line: 1),
|
|
reason: "File shouldn't start with whitespace: " +
|
|
"currently starts with \(countOfLeadingWhitespace) whitespace characters")]
|
|
}
|
|
return []
|
|
}
|
|
|
|
func forceCastViolations(file: File) -> [StyleViolation] {
|
|
return flatMap(NSRegularExpression(pattern: "as!", options: nil, error: nil)) { regex in
|
|
let range = NSRange(location: 0, length: count(file.contents.utf16))
|
|
let syntax = SyntaxMap(file: file)
|
|
let matches = regex.matchesInString(file.contents, options: nil, range: range)
|
|
return map(matches as? [NSTextCheckingResult]) { matches in
|
|
return compact(matches.map { match in
|
|
let offset = match.range.location
|
|
let tokenAtOffset = syntax.tokens.filter({ $0.offset == offset }).first
|
|
let syntaxKind = flatMap(tokenAtOffset?.type) { SyntaxKind(rawValue: $0) }
|
|
if syntaxKind != .Keyword {
|
|
return nil
|
|
}
|
|
return StyleViolation(type: .ForceCast,
|
|
location: Location(file: self, offset: offset),
|
|
reason: "Force casts should be avoided")
|
|
})
|
|
}
|
|
} ?? []
|
|
}
|
|
|
|
func trailingLineWhitespaceViolations(lines: [Line]) -> [StyleViolation] {
|
|
return lines.map { line in
|
|
(
|
|
index: line.index,
|
|
trailingWhitespaceCount: line.content.countOfTailingCharactersInSet(
|
|
NSCharacterSet.whitespaceCharacterSet()
|
|
)
|
|
)
|
|
}.filter {
|
|
$0.trailingWhitespaceCount > 0
|
|
}.map {
|
|
StyleViolation(type: .TrailingWhitespace,
|
|
location: Location(file: self.path, line: $0.index),
|
|
reason: "Line #\($0.index) should have no trailing whitespace: " +
|
|
"current has \($0.trailingWhitespaceCount) trailing whitespace characters")
|
|
}
|
|
}
|
|
|
|
func trailingNewlineViolations(contents: String) -> [StyleViolation] {
|
|
let countOfTrailingNewlines = contents.countOfTailingCharactersInSet(
|
|
NSCharacterSet.newlineCharacterSet()
|
|
)
|
|
if countOfTrailingNewlines != 1 {
|
|
return [StyleViolation(type: .TrailingNewline,
|
|
location: Location(file: self.path),
|
|
reason: "File should have a single trailing newline: " +
|
|
"currently has \(countOfTrailingNewlines)")]
|
|
}
|
|
return []
|
|
}
|
|
|
|
func fileLengthViolations(lines: [Line]) -> [StyleViolation] {
|
|
if lines.count > 400 {
|
|
return [StyleViolation(type: .Length,
|
|
location: Location(file: self.path),
|
|
reason: "File should contain 400 lines or less: currently contains \(lines.count)")]
|
|
}
|
|
return []
|
|
}
|
|
|
|
func astViolationsInDictionary(dictionary: XPCDictionary) -> [StyleViolation] {
|
|
return reduce((dictionary["key.substructure"] as? XPCArray ?? []).map {
|
|
// swiftlint:disable_rule:force_cast (safe to force cast)
|
|
let subDict = $0 as! XPCDictionary
|
|
// swiftlint:enable_rule:force_cast
|
|
var violations = self.astViolationsInDictionary(subDict)
|
|
if let kindString = subDict["key.kind"] as? String,
|
|
let kind = flatMap(kindString, { SwiftDeclarationKind(rawValue: $0) }) {
|
|
violations.extend(self.validateTypeName(kind, dict: subDict))
|
|
violations.extend(self.validateVariableName(kind, dict: subDict))
|
|
violations.extend(self.validateTypeBodyLength(kind, dict: subDict))
|
|
}
|
|
return violations
|
|
}, [], +)
|
|
}
|
|
|
|
func validateTypeBodyLength(kind: SwiftDeclarationKind, dict: XPCDictionary) ->
|
|
[StyleViolation] {
|
|
let typeKinds: [SwiftDeclarationKind] = [
|
|
.Class,
|
|
.Struct,
|
|
.Enum
|
|
]
|
|
if !contains(typeKinds, kind) {
|
|
return []
|
|
}
|
|
var violations = [StyleViolation]()
|
|
if let name = dict["key.name"] as? String,
|
|
let offset = flatMap(dict["key.offset"] as? Int64, { Int($0) }),
|
|
let bodyOffset = flatMap(dict["key.bodyoffset"] as? Int64, { Int($0) }),
|
|
let bodyLength = flatMap(dict["key.bodylength"] as? Int64, { Int($0) }) {
|
|
let location = Location(file: self, offset: offset)
|
|
let startLine = self.contents.lineAndCharacterForByteOffset(bodyOffset)
|
|
let endLine = self.contents.lineAndCharacterForByteOffset(bodyOffset + bodyLength)
|
|
if let startLine = startLine?.line, let endLine = endLine?.line
|
|
where endLine - startLine > 200 {
|
|
violations.append(StyleViolation(type: .Length,
|
|
location: location,
|
|
reason: "Type body should be span 200 lines or less: currently spans " +
|
|
"\(endLine - startLine) lines"))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateTypeName(kind: SwiftDeclarationKind, dict: XPCDictionary) -> [StyleViolation] {
|
|
let typeKinds: [SwiftDeclarationKind] = [
|
|
.Class,
|
|
.Struct,
|
|
.Typealias,
|
|
.Enum,
|
|
.Enumelement
|
|
]
|
|
if !contains(typeKinds, kind) {
|
|
return []
|
|
}
|
|
var violations = [StyleViolation]()
|
|
if let name = dict["key.name"] as? String,
|
|
let offset = flatMap(dict["key.offset"] as? Int64, { Int($0) }) {
|
|
let location = Location(file: self, offset: offset)
|
|
let nameCharacterSet = NSCharacterSet(charactersInString: name)
|
|
if !NSCharacterSet.alphanumericCharacterSet().isSupersetOfSet(nameCharacterSet) {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Type name should only contain alphanumeric characters: '\(name)'"))
|
|
} else if !name.substringToIndex(name.startIndex.successor()).isUppercase() {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Type name should start with an uppercase character: '\(name)'"))
|
|
} else if count(name) < 3 || count(name) > 40 {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Type name should be between 3 and 40 characters in length: " +
|
|
"'\(name)'"))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateVariableName(kind: SwiftDeclarationKind, dict: XPCDictionary) -> [StyleViolation] {
|
|
let variableKinds: [SwiftDeclarationKind] = [
|
|
.VarClass,
|
|
.VarGlobal,
|
|
.VarInstance,
|
|
.VarLocal,
|
|
.VarParameter,
|
|
.VarStatic
|
|
]
|
|
if !contains(variableKinds, kind) {
|
|
return []
|
|
}
|
|
var violations = [StyleViolation]()
|
|
if let name = dict["key.name"] as? String,
|
|
let offset = flatMap(dict["key.offset"] as? Int64, { Int($0) }) {
|
|
let location = Location(file: self, offset: offset)
|
|
let nameCharacterSet = NSCharacterSet(charactersInString: name)
|
|
if !NSCharacterSet.alphanumericCharacterSet().isSupersetOfSet(nameCharacterSet) {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Variable name should only contain alphanumeric characters: '\(name)'"))
|
|
} else if name.substringToIndex(name.startIndex.successor()).isUppercase() {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Variable name should start with a lowercase character: '\(name)'"))
|
|
} else if count(name) < 3 || count(name) > 40 {
|
|
violations.append(StyleViolation(type: .NameFormat,
|
|
location: location,
|
|
reason: "Variable name should be between 3 and 40 characters in length: " +
|
|
"'\(name)'"))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
func lines() -> [Line] {
|
|
var lines = [Line]()
|
|
var lineIndex = 1
|
|
enumerateLines { line, stop in
|
|
lines.append((lineIndex++, line))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func isUppercase() -> Bool {
|
|
return self == uppercaseString
|
|
}
|
|
|
|
func countOfTailingCharactersInSet(characterSet: NSCharacterSet) -> Int {
|
|
return String(reverse(self)).countOfLeadingCharactersInSet(characterSet)
|
|
}
|
|
}
|
|
|
|
extension NSString {
|
|
public func lineAndCharacterForByteOffset(offset: Int) -> (line: Int, character: Int)? {
|
|
return flatMap(byteRangeToNSRange(start: offset, length: 0)) { range in
|
|
var numberOfLines = 0
|
|
var index = 0
|
|
var lineRangeStart = 0
|
|
while index < length {
|
|
numberOfLines++
|
|
if index <= range.location {
|
|
lineRangeStart = numberOfLines
|
|
index = NSMaxRange(self.lineRangeForRange(NSRange(location: index, length: 1)))
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return (lineRangeStart, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct Linter {
|
|
private let file: File
|
|
private let structure: Structure
|
|
|
|
public var styleViolations: [StyleViolation] {
|
|
return file.astViolationsInDictionary(structure.dictionary) + stringViolations
|
|
}
|
|
|
|
private var stringViolations: [StyleViolation] {
|
|
let lines = file.contents.lines()
|
|
// FIXME: Using '+' to concatenate these arrays would be nicer,
|
|
// but slows the compiler to a crawl.
|
|
var violations = file.lineLengthViolations(lines)
|
|
violations.extend(file.leadingWhitespaceViolations(file.contents))
|
|
violations.extend(file.trailingLineWhitespaceViolations(lines))
|
|
violations.extend(file.trailingNewlineViolations(file.contents))
|
|
violations.extend(file.forceCastViolations(file))
|
|
violations.extend(file.fileLengthViolations(lines))
|
|
return violations
|
|
}
|
|
|
|
/**
|
|
Initialize a Linter by passing in a File.
|
|
|
|
:param: file File to lint.
|
|
*/
|
|
public init(file: File) {
|
|
self.file = file
|
|
structure = Structure(file: file)
|
|
}
|
|
}
|