mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
215 lines
7.4 KiB
Swift
215 lines
7.4 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
import SwiftSyntax
|
|
|
|
@SwiftSyntaxRule(optIn: true)
|
|
struct ExpiringTodoRule: Rule {
|
|
enum ExpiryViolationLevel {
|
|
case approachingExpiry
|
|
case expired
|
|
case badFormatting
|
|
|
|
var reason: String {
|
|
switch self {
|
|
case .approachingExpiry:
|
|
"TODO/FIXME is approaching its expiry and should be resolved soon"
|
|
case .expired:
|
|
"TODO/FIXME has expired and must be resolved"
|
|
case .badFormatting:
|
|
"Expiring TODO/FIXME is incorrectly formatted"
|
|
}
|
|
}
|
|
}
|
|
|
|
static let description = RuleDescription(
|
|
identifier: "expiring_todo",
|
|
name: "Expiring Todo",
|
|
description: "TODOs and FIXMEs should be resolved prior to their expiry date.",
|
|
kind: .lint,
|
|
nonTriggeringExamples: [
|
|
Example("// notaTODO:"),
|
|
Example("// notaFIXME:"),
|
|
Example("// TODO: [12/31/9999]"),
|
|
Example("// TODO(note)"),
|
|
Example("// FIXME(note)"),
|
|
Example("/* FIXME: */"),
|
|
Example("/* TODO: */"),
|
|
Example("/** FIXME: */"),
|
|
Example("/** TODO: */"),
|
|
],
|
|
triggeringExamples: [
|
|
Example("// TODO: [↓10/14/2019]"),
|
|
Example("// FIXME: [↓10/14/2019]"),
|
|
Example("// FIXME: [↓1/14/2019]"),
|
|
Example("// FIXME: [↓10/14/2019]"),
|
|
Example("// TODO: [↓9999/14/10]"),
|
|
].skipWrappingInCommentTests()
|
|
)
|
|
|
|
var configuration = ExpiringTodoConfiguration()
|
|
}
|
|
|
|
private extension ExpiringTodoRule {
|
|
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
|
|
private lazy var regex: NSRegularExpression = {
|
|
let pattern = #"""
|
|
\b(?:TODO|FIXME)(?::|\b)(?:(?!\b(?:TODO|FIXME)(?::|\b)).)*?\#
|
|
\\#(configuration.dateDelimiters.opening)\#
|
|
(\d{1,4}\\#(configuration.dateSeparator)\d{1,4}\\#(configuration.dateSeparator)\d{1,4})\#
|
|
\\#(configuration.dateDelimiters.closing)
|
|
"""#
|
|
return SwiftLintCore.regex(pattern)
|
|
}()
|
|
|
|
override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind {
|
|
// Process each comment individually
|
|
for token in node.tokens(viewMode: .sourceAccurate) {
|
|
processTrivia(
|
|
token.leadingTrivia,
|
|
baseOffset: token.position.utf8Offset
|
|
)
|
|
processTrivia(
|
|
token.trailingTrivia,
|
|
baseOffset: token.endPositionBeforeTrailingTrivia.utf8Offset
|
|
)
|
|
}
|
|
|
|
return .skipChildren
|
|
}
|
|
|
|
private func processTrivia(_ trivia: Trivia, baseOffset: Int) {
|
|
var triviaOffset = baseOffset
|
|
|
|
for (index, piece) in trivia.enumerated() {
|
|
defer { triviaOffset += piece.sourceLength.utf8Length }
|
|
|
|
guard let commentText = piece.commentText else { continue }
|
|
|
|
// Handle multiline comments by checking consecutive line comments
|
|
if piece.isLineComment {
|
|
var combinedText = commentText
|
|
let currentOffset = triviaOffset
|
|
|
|
// Look ahead for consecutive line comments
|
|
let remainingTrivia = trivia.dropFirst(index + 1)
|
|
|
|
for nextPiece in remainingTrivia {
|
|
if case .lineComment(let nextText) = nextPiece {
|
|
// Check if it's a continuation (starts with //)
|
|
if nextText.hasPrefix("//") {
|
|
combinedText += "\n" + nextText
|
|
} else {
|
|
break
|
|
}
|
|
} else if !nextPiece.isWhitespace {
|
|
break
|
|
}
|
|
}
|
|
|
|
processComment(combinedText, offset: currentOffset)
|
|
} else {
|
|
processComment(commentText, offset: triviaOffset)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processComment(_ commentText: String, offset: Int) {
|
|
let matches = regex.matches(in: commentText, options: [], range: commentText.fullNSRange)
|
|
let nsStringComment = commentText.bridge()
|
|
|
|
for match in matches {
|
|
guard match.numberOfRanges > 1 else { continue }
|
|
|
|
// Get the date capture group (second capture group, index 1)
|
|
let dateRange = match.range(at: 1)
|
|
guard dateRange.location != NSNotFound else { continue }
|
|
|
|
let matchOffset = offset + dateRange.location
|
|
let matchPosition = AbsolutePosition(utf8Offset: matchOffset)
|
|
|
|
let dateString = nsStringComment.substring(with: dateRange)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if let violationLevel = getViolationLevel(for: parseDate(dateString: dateString)),
|
|
let severity = getSeverity(for: violationLevel) {
|
|
let violation = ReasonedRuleViolation(
|
|
position: matchPosition,
|
|
reason: violationLevel.reason,
|
|
severity: severity
|
|
)
|
|
violations.append(violation)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func parseDate(dateString: String) -> Date? {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = .current
|
|
formatter.dateFormat = configuration.dateFormat
|
|
return formatter.date(from: dateString)
|
|
}
|
|
|
|
private func getSeverity(for violationLevel: ExpiryViolationLevel) -> ViolationSeverity? {
|
|
switch violationLevel {
|
|
case .approachingExpiry:
|
|
configuration.approachingExpirySeverity.severity
|
|
case .expired:
|
|
configuration.expiredSeverity.severity
|
|
case .badFormatting:
|
|
configuration.badFormattingSeverity.severity
|
|
}
|
|
}
|
|
|
|
private func getViolationLevel(for expiryDate: Date?) -> ExpiryViolationLevel? {
|
|
guard let expiryDate else {
|
|
return .badFormatting
|
|
}
|
|
|
|
guard expiryDate.isAfterToday else {
|
|
return .expired
|
|
}
|
|
|
|
let approachingDate = Calendar.current.date(
|
|
byAdding: .day,
|
|
value: -configuration.approachingExpiryThreshold,
|
|
to: expiryDate
|
|
)
|
|
|
|
guard let approachingDate else {
|
|
return nil
|
|
}
|
|
|
|
return approachingDate.isAfterToday ?
|
|
nil :
|
|
.approachingExpiry
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Date {
|
|
var isAfterToday: Bool {
|
|
Calendar.current.compare(.init(), to: self, toGranularity: .day) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private extension TriviaPiece {
|
|
var isLineComment: Bool {
|
|
switch self {
|
|
case .lineComment, .docLineComment:
|
|
true
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
|
|
var commentText: String? {
|
|
switch self {
|
|
case .lineComment(let text), .blockComment(let text),
|
|
.docLineComment(let text), .docBlockComment(let text):
|
|
text
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
}
|