SwiftLint/Source/SwiftLintFramework/Rules/Style/NumberSeparatorRule.swift

163 lines
6.2 KiB
Swift

import Foundation
import SourceKittenFramework
public struct NumberSeparatorRule: OptInRule, CorrectableRule, ConfigurationProviderRule {
public var configuration = NumberSeparatorConfiguration(
minimumLength: 0,
minimumFractionLength: nil,
excludeRanges: []
)
public init() {}
public static let description = RuleDescription(
identifier: "number_separator",
name: "Number Separator",
description: "Underscores should be used as thousand separator in large decimal numbers.",
kind: .style,
nonTriggeringExamples: NumberSeparatorRuleExamples.nonTriggeringExamples,
triggeringExamples: NumberSeparatorRuleExamples.triggeringExamples,
corrections: NumberSeparatorRuleExamples.corrections
)
public func validate(file: SwiftLintFile) -> [StyleViolation] {
return violatingRanges(in: file).map { range, _ in
return StyleViolation(ruleDescription: type(of: self).description,
severity: configuration.severityConfiguration.severity,
location: Location(file: file, characterOffset: range.location))
}
}
private func violatingRanges(in file: SwiftLintFile) -> [(NSRange, String)] {
let numberTokens = file.syntaxMap.tokens.filter { $0.kind == .number }
return numberTokens.compactMap { (token: SwiftLintSyntaxToken) -> (NSRange, String)? in
guard
let content = file.contents(for: token),
isDecimal(number: content),
!isInValidRanges(number: content)
else {
return nil
}
let signs = CharacterSet(charactersIn: "+-")
let exponential = CharacterSet(charactersIn: "eE")
guard let nonSign = content.components(separatedBy: signs).last,
case let exponentialComponents = nonSign.components(separatedBy: exponential),
let nonExponential = exponentialComponents.first else {
return nil
}
let components = nonExponential.components(separatedBy: ".")
var validFraction = true
var expectedFraction: String?
if components.count == 2, let fractionSubstring = components.last {
let result = isValid(number: fractionSubstring, isFraction: true)
validFraction = result.0
expectedFraction = result.1
}
guard let integerSubstring = components.first,
case let (valid, expected) = isValid(number: integerSubstring, isFraction: false),
!valid || !validFraction,
let range = file.contents.bridge().byteRangeToNSRange(start: token.offset,
length: token.length) else {
return nil
}
var corrected = ""
let hasSign = content.countOfLeadingCharacters(in: signs) == 1
if hasSign {
corrected += String(content.prefix(1))
}
corrected += expected
if let fraction = expectedFraction {
corrected += "." + fraction
}
if exponentialComponents.count == 2, let exponential = exponentialComponents.last {
let exponentialSymbol = content.contains("e") ? "e" : "E"
corrected += exponentialSymbol + exponential
}
return (range, corrected)
}
}
public func correct(file: SwiftLintFile) -> [Correction] {
let violatingRanges = self.violatingRanges(in: file).filter { range, _ in
return !file.ruleEnabled(violatingRanges: [range], for: self).isEmpty
}
var correctedContents = file.contents
var adjustedLocations = [Int]()
for (violatingRange, correction) in violatingRanges.reversed() {
if let indexRange = correctedContents.nsrangeToIndexRange(violatingRange) {
correctedContents = correctedContents.replacingCharacters(in: indexRange, with: correction)
adjustedLocations.insert(violatingRange.location, at: 0)
}
}
file.write(correctedContents)
return adjustedLocations.map {
Correction(ruleDescription: type(of: self).description,
location: Location(file: file, characterOffset: $0))
}
}
private func isDecimal(number: String) -> Bool {
let lowercased = number.lowercased()
let prefixes = ["0x", "0o", "0b"].flatMap { [$0, "-" + $0, "+" + $0] }
return !prefixes.contains { lowercased.hasPrefix($0) }
}
private func isInValidRanges(number: String) -> Bool {
let doubleValue = Double(number.replacingOccurrences(of: "_", with: ""))
if let doubleValue = doubleValue, configuration.excludeRanges.contains(where: { $0.contains(doubleValue) }) {
return true
}
return false
}
private func isValid(number: String, isFraction: Bool) -> (Bool, String) {
var correctComponents = [String]()
let clean = number.replacingOccurrences(of: "_", with: "")
let minimumLength: Int
if isFraction {
minimumLength = configuration.minimumFractionLength ?? configuration.minimumLength
} else {
minimumLength = configuration.minimumLength
}
let shouldAddSeparators = clean.count >= minimumLength
var numerals = 0
for char in reversedIfNeeded(clean, reversed: !isFraction) {
defer { correctComponents.append(String(char)) }
guard char.unicodeScalars.allSatisfy(CharacterSet.decimalDigits.contains) else { continue }
if numerals % 3 == 0 && numerals > 0 && shouldAddSeparators {
correctComponents.append("_")
}
numerals += 1
}
let expected = reversedIfNeeded(correctComponents, reversed: !isFraction).joined()
return (expected == number, expected)
}
private func reversedIfNeeded<T>(_ collection: T, reversed: Bool) -> [T.Element] where T: Collection {
if reversed {
return collection.reversed()
}
return Array(collection)
}
}