diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 8753d76f5..4c5033567 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -7,6 +7,8 @@ steps: - make bazel_test - label: "SwiftPM" commands: + - echo "+++ Generate Template Rule" + - swift run swiftlint-dev rule-template FooBarBaz --rewriter --id foo_bar --config --kind metrics --severity error - echo "+++ Test" - make spm_test env: diff --git a/CHANGELOG.md b/CHANGELOG.md index def35ac2f..4a2329635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ ### Experimental -* None. +* Introduce `swiftlint-dev` command line tool that's intended to help to develop SwiftLint by encapsulating repetitive + tasks. It can already be used to generate templates for new rules including optional configurations and tests. Run + `swift run swiftlint-dev rule-template -h` to get an overview of the command's usage and the available customization + options. + [SimplyDanny](https://github.com/SimplyDanny) ### Enhancements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fe766a55..69257ef94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,10 @@ open a pull request and watch the CI results carefully. They include all the nec ## Rules -New rules should be added in the `Source/SwiftLintBuiltInRules/Rules` directory. +New rules should be added in the `Source/SwiftLintBuiltInRules/Rules` directory. We recommend to use the `swiftlint-dev` +command line tool to generate scaffolds for new rules and their configurations. After having the repository cloned, run +`swift run swiftlint-dev rule-template ` to create the new rule at the correct location. Refer to the command's +help `-h/--help` for customization options. Run `make sourcery` afterwards to register the new rule and its tests. Prefer implementing new rules with the help of SwiftSyntax. Look for the `@SwiftSyntaxRule` attribute for examples and use the same on your own rule. diff --git a/Package.swift b/Package.swift index d085a7f60..305c7b313 100644 --- a/Package.swift +++ b/Package.swift @@ -51,6 +51,14 @@ let package = Package( ], swiftSettings: swiftFeatures + strictConcurrency ), + .executableTarget( + name: "swiftlint-dev", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "SwiftLintCore", + ], + swiftSettings: swiftFeatures + strictConcurrency + ), .target( name: "SwiftLintFramework", dependencies: [ diff --git a/Source/SwiftLintCore/Extensions/String+SwiftLint.swift b/Source/SwiftLintCore/Extensions/String+SwiftLint.swift index 41c19f8bf..d89f5130f 100644 --- a/Source/SwiftLintCore/Extensions/String+SwiftLint.swift +++ b/Source/SwiftLintCore/Extensions/String+SwiftLint.swift @@ -119,10 +119,12 @@ public extension String { return String(dropFirst(prefix.count)) } - func indent(by spaces: Int) -> String { - components(separatedBy: "\n") - .map { String(repeating: " ", count: spaces) + $0 } - .joined(separator: "\n") + func indent(by spaces: Int, skipFirst: Bool = false, skipEmptyLines: Bool = true) -> String { + let lines = components(separatedBy: "\n") + if skipFirst, let firstLine = lines.first { + return firstLine + "\n" + lines.dropFirst().indent(by: spaces, skipEmptyLines: skipEmptyLines) + } + return lines.indent(by: spaces, skipEmptyLines: skipEmptyLines) } func linesPrefixed(with prefix: Self) -> Self { @@ -142,3 +144,15 @@ public extension String { return nil } } + +private extension Sequence where Element == String { + func indent(by spaces: Int, skipEmptyLines: Bool = true) -> String { + map { line in + if skipEmptyLines, line.isEmpty { + return line + } + return String(repeating: " ", count: spaces) + line + } + .joined(separator: "\n") + } +} diff --git a/Source/SwiftLintCore/Models/RuleKind.swift b/Source/SwiftLintCore/Models/RuleKind.swift index f5248f9ee..0ac2f1f1b 100644 --- a/Source/SwiftLintCore/Models/RuleKind.swift +++ b/Source/SwiftLintCore/Models/RuleKind.swift @@ -1,5 +1,5 @@ /// All the possible rule kinds (categories). -public enum RuleKind: String, Codable, Sendable { +public enum RuleKind: String, CaseIterable, Codable, Sendable { /// Describes rules that validate Swift source conventions. case lint /// Describes rules that validate common practices in the Swift community. diff --git a/Source/SwiftLintCore/Models/ViolationSeverity.swift b/Source/SwiftLintCore/Models/ViolationSeverity.swift index a833de23e..9e0ea7ab8 100644 --- a/Source/SwiftLintCore/Models/ViolationSeverity.swift +++ b/Source/SwiftLintCore/Models/ViolationSeverity.swift @@ -1,6 +1,6 @@ /// The magnitude of a `StyleViolation`. @AcceptableByConfigurationElement -public enum ViolationSeverity: String, Comparable, Codable, Sendable, InlinableOptionType { +public enum ViolationSeverity: String, Comparable, CaseIterable, Codable, Sendable, InlinableOptionType { /// Non-fatal. If using SwiftLint as an Xcode build phase, Xcode will mark the build as having succeeded. case warning /// Fatal. If using SwiftLint as an Xcode build phase, Xcode will mark the build as having failed. diff --git a/Source/swiftlint-dev/RuleTemplate.swift b/Source/swiftlint-dev/RuleTemplate.swift new file mode 100644 index 000000000..453855808 --- /dev/null +++ b/Source/swiftlint-dev/RuleTemplate.swift @@ -0,0 +1,204 @@ +import ArgumentParser +import Foundation +import SwiftLintCore + +extension SwiftLintDev { + struct RuleTemplate: AsyncParsableCommand { + // swiftlint:disable:next force_try + private static let camelCaseRegex = try! NSRegularExpression(pattern: "(?(.\(severity))" + ) + var extensionContent = [ + """ + final class Visitor: ViolationsSyntaxVisitor { + } + """, + ] + if rewriter { + extensionContent.append(""" + + final class Rewriter: ViolationsSyntaxRewriter { + } + """) + } + return """ + import SwiftLintCore + import SwiftSyntax + + @SwiftSyntaxRule(\(attributeArguments.joined(separator: ", "))) + struct \(name)Rule: Rule { + \(configDecl) + + static let description = RuleDescription( + \(ruleDescriptionArguments.joined(separator: ",\n").indent(by: 8, skipFirst: true)) + ) + } + + private extension \(name)Rule { + \(extensionContent.joined(separator: "\n").indent(by: 4, skipFirst: true)) + } + + """ + } + + var configTemplate: String { + """ + import SwiftLintCore + + @AutoConfigParser + struct \(name)Configuration: SeverityBasedRuleConfiguration { + typealias Parent = \(name)Rule + + @ConfigurationElement(key: "severity") + private(set) var severityConfiguration = SeverityConfiguration(.error) + } + + """ + } + + var testTemplate: String { + """ + @testable import SwiftLintBuiltInRules + import TestHelpers + + final class \(name)RuleTests: SwiftLintTestCase { + func test() { + verifyRule(\(name)Rule.description, ruleConfiguration: []) + } + } + + """ + } +} + +extension RuleKind: ExpressibleByArgument { + // Automatic conformance. +} + +extension ViolationSeverity: ExpressibleByArgument { + // Automatic conformance. +} diff --git a/Source/swiftlint-dev/SwiftLintDev.swift b/Source/swiftlint-dev/SwiftLintDev.swift new file mode 100644 index 000000000..e5c40ba0d --- /dev/null +++ b/Source/swiftlint-dev/SwiftLintDev.swift @@ -0,0 +1,12 @@ +import ArgumentParser + +@main +struct SwiftLintDev: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "swiftlint-dev", + abstract: "A tool to help develop SwiftLint.", + subcommands: [ + RuleTemplate.self, + ] + ) +}