150 lines
5.9 KiB
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)
|
|
}
|
|
}
|