Files
SwiftLint/Source/SwiftLintBuiltInRules/Rules/Lint/ExpiringTodoRule.swift
2025-10-08 08:12:05 +02:00

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
}
}
}