import Foundation import SourceKittenFramework public struct LetVarWhitespaceRule: ConfigurationProviderRule, OptInRule, AutomaticTestableRule { public var configuration = SeverityConfiguration(.warning) public init() {} public static let description = RuleDescription( identifier: "let_var_whitespace", name: "Variable Declaration Whitespace", description: "Let and var should be separated from other statements by a blank line.", kind: .style, nonTriggeringExamples: [ "let a = 0\nvar x = 1\n\nx = 2\n", "a = 5\n\nvar x = 1\n", "struct X {\n\tvar a = 0\n}\n", "let a = 1 +\n\t2\nlet b = 5\n", "var x: Int {\n\treturn 0\n}\n", "var x: Int {\n\tlet a = 0\n\n\treturn a\n}\n", "#if os(macOS)\nlet a = 0\n#endif\n", "@available(swift 4)\nlet a = 0\n", "class C {\n\t@objc\n\tvar s: String = \"\"\n}", "class C {\n\t@objc\n\tfunc a() {}\n}", "class C {\n\tvar x = 0\n\tlazy\n\tvar y = 0\n}\n", "@available(OSX, introduced: 10.6)\n@available(*, deprecated)\nvar x = 0\n", "// swiftlint:disable superfluous_disable_command\n// swiftlint:disable force_cast\n\nlet x = bar as! Bar", "var x: Int {\n\tlet a = 0\n\treturn a\n}\n" // don't trigger on local vars ], triggeringExamples: [ "var x = 1\n↓x = 2\n", "\na = 5\n↓var x = 1\n", "struct X {\n\tlet a\n\t↓func x() {}\n}\n", "var x = 0\n↓@objc func f() {}\n", "var x = 0\n↓@objc\n\tfunc f() {}\n", "@objc func f() {\n}\n↓var x = 0\n" ] ) public func validate(file: File) -> [StyleViolation] { var attributeLines = attributeLineNumbers(file: file) let varLines = varLetLineNumbers(file: file, structure: file.structure.dictionary.substructure, attributeLines: &attributeLines) let skippedLines = skippedLineNumbers(file: file) var violations = [StyleViolation]() for (index, line) in file.lines.enumerated() { guard !varLines.contains(index) && !skippedLines.contains(index) else { continue } let trimmed = line.content.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { continue } // Precedes var/let and has text not ending with { if linePrecedesVar(index, varLines, skippedLines) { if !trimmed.hasSuffix("{") && !file.lines[index + 1].content.trimmingCharacters(in: .whitespaces).hasPrefix("}") { violated(&violations, file, index + 1) } } // Follows var/let and has text not starting with } if lineFollowsVar(index, varLines, skippedLines) { if !trimmed.hasPrefix("}") && !file.lines[index - 1].content.trimmingCharacters(in: .whitespaces).hasSuffix("{") { violated(&violations, file, index) } } } return violations } private func linePrecedesVar(_ lineNumber: Int, _ varLines: Set, _ skippedLines: Set) -> Bool { return lineNeighborsVar(lineNumber, varLines, skippedLines, 1) } private func lineFollowsVar(_ lineNumber: Int, _ varLines: Set, _ skippedLines: Set) -> Bool { return lineNeighborsVar(lineNumber, varLines, skippedLines, -1) } private func lineNeighborsVar(_ lineNumber: Int, _ varLines: Set, _ skippedLines: Set, _ increment: Int) -> Bool { if varLines.contains(lineNumber + increment) { return true } var prevLine = lineNumber while skippedLines.contains(prevLine) { if varLines.contains(prevLine + increment) { return true } prevLine += increment } return false } private func violated(_ violations: inout [StyleViolation], _ file: File, _ line: Int) { let content = file.lines[line].content let startIndex = content.rangeOfCharacter(from: CharacterSet.whitespaces.inverted)?.lowerBound ?? content.startIndex let offset = content.distance(from: content.startIndex, to: startIndex) let location = Location(file: file, characterOffset: offset + file.lines[line].range.location) violations.append(StyleViolation(ruleDescription: LetVarWhitespaceRule.description, severity: configuration.severity, location: location)) } private func lineOffsets(file: File, statement: [String: SourceKitRepresentable]) -> (Int, Int)? { guard let offset = statement.offset, let length = statement.length else { return nil } let startLine = file.line(byteOffset: offset, startFrom: 0) let endLine = file.line(byteOffset: offset + length, startFrom: max(startLine, 0)) return (startLine, endLine) } // Collects all the line numbers containing var or let declarations private func varLetLineNumbers(file: File, structure: [[String: SourceKitRepresentable]], attributeLines: inout Set) -> Set { var result = Set() for statement in structure { guard let kind = statement.kind, let (startLine, endLine) = lineOffsets(file: file, statement: statement) else { continue } if SwiftDeclarationKind.nonVarAttributableKinds.contains(where: { $0.rawValue == kind }) { if attributeLines.contains(startLine) { attributeLines.remove(startLine) } } if SwiftDeclarationKind.varKinds.contains(where: { $0.rawValue == kind }) { var lines = Set(startLine...((endLine < 0) ? file.lines.count : endLine)) var previousLine = startLine - 1 // Include preceding attributes while attributeLines.contains(previousLine) { lines.insert(previousLine) attributeLines.remove(previousLine) previousLine -= 1 } // Exclude the body where the accessors are if let bodyOffset = statement.bodyOffset, let bodyLength = statement.bodyLength { let bodyStart = file.line(byteOffset: bodyOffset, startFrom: startLine) + 1 let bodyEnd = file.line(byteOffset: bodyOffset + bodyLength, startFrom: bodyStart) - 1 if bodyStart <= bodyEnd { lines.subtract(Set(bodyStart...bodyEnd)) } } result.formUnion(lines) } let substructure = statement.substructure if !substructure.isEmpty { result.formUnion(varLetLineNumbers(file: file, structure: substructure, attributeLines: &attributeLines)) } } return result } // Collects all the line numbers containing comments or #if/#endif private func skippedLineNumbers(file: File) -> Set { var result = Set() let syntaxMap = file.syntaxMap for token in syntaxMap.tokens where token.type == SyntaxKind.comment.rawValue || token.type == SyntaxKind.docComment.rawValue { let startLine = file.line(byteOffset: token.offset, startFrom: 0) let endLine = file.line(byteOffset: token.offset + token.length, startFrom: startLine) if startLine <= endLine { result.formUnion(Set(startLine...endLine)) } } let directives = ["#if", "#elseif", "#else", "#endif", "#!"] let directiveLines = file.lines.filter { let trimmed = $0.content.trimmingCharacters(in: .whitespaces) return directives.contains(where: trimmed.hasPrefix) } result.formUnion(directiveLines.map { $0.index - 1 }) return result } // Collects all the line numbers containing attributes but not declarations // other than let/var private func attributeLineNumbers(file: File) -> Set { return Set(file.syntaxMap.tokens.compactMap({ token in if token.type == SyntaxKind.attributeBuiltin.rawValue { return file.line(byteOffset: token.offset) } return nil })) } } private extension SwiftDeclarationKind { // The various kinds of let/var declarations static let varKinds: [SwiftDeclarationKind] = [.varGlobal, .varClass, .varStatic, .varInstance] // Declarations other than let & var that can have attributes static let nonVarAttributableKinds: [SwiftDeclarationKind] = [ .class, .struct, .functionFree, .functionSubscript, .functionDestructor, .functionConstructor, .functionMethodClass, .functionMethodStatic, .functionMethodInstance, .functionOperator, .functionOperatorInfix, .functionOperatorPrefix, .functionOperatorPostfix ] } private extension File { // Zero-based line number for the given a byte offset func line(byteOffset: Int, startFrom: Int = 0) -> Int { for index in startFrom.. byteOffset { return index } } return -1 } }