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

150 lines
5.9 KiB
Swift

import Foundation
import SourceKittenFramework
public struct NumberSeparatorRule: CorrectableRule, ConfigurationProviderRule {
public var configuration = NumberSeparatorConfiguration(minimumLength: 0, minimumFractionLength: nil)
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,
isOptIn: true,
nonTriggeringExamples: NumberSeparatorRuleExamples.nonTriggeringExamples,
triggeringExamples: NumberSeparatorRuleExamples.triggeringExamples,
corrections: NumberSeparatorRuleExamples.corrections
)
public func validate(file: File) -> [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: File) -> [(NSRange, String)] {
let numberTokens = file.syntaxMap.tokens.filter { SyntaxKind(rawValue: $0.type) == .number }
return numberTokens.compactMap { (token: SyntaxToken) -> (NSRange, String)? in
guard let content = contentFrom(file: file, token: token),
isDecimal(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: File) -> [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.filter { lowercased.hasPrefix($0) }.isEmpty
}
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
for (idx, char) in reversedIfNeeded(Array(clean), reversed: !isFraction).enumerated() {
if idx % 3 == 0 && idx > 0 && shouldAddSeparators {
correctComponents.append("_")
}
correctComponents.append(String(char))
}
let expected = reversedIfNeeded(correctComponents, reversed: !isFraction).joined()
return (expected == number, expected)
}
private func reversedIfNeeded<T>(_ array: [T], reversed: Bool) -> [T] {
if reversed {
return array.reversed()
}
return array
}
private func contentFrom(file: File, token: SyntaxToken) -> String? {
return file.contents.bridge().substringWithByteRange(start: token.offset,
length: token.length)
}
}