Introduce internal CLI to simplify development tasks (#6032)

Start with a command that generates a template for a new SwiftSyntax
rule. This tool could remain separate or be merged into the official
binary under a `dev` sub-command at a later point in time.
This commit is contained in:
Danny Mösch
2025-03-23 01:21:41 +01:00
committed by GitHub
parent 63fea48d83
commit 043f9cac5b
9 changed files with 255 additions and 8 deletions
+2
View File
@@ -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:
+5 -1
View File
@@ -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
+4 -1
View File
@@ -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 <RuleName>` 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.
+8
View File
@@ -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: [
@@ -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")
}
}
+1 -1
View File
@@ -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.
@@ -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.
+204
View File
@@ -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: "(?<!^)(?=[A-Z])")
static let configuration = CommandConfiguration(
commandName: "rule-template",
abstract: "Generate a template for a new SwiftLint rule.",
discussion: """
This command generates a template for a SwiftLint rule. It creates a new file with the
specified rule name and populates it with a basic structure. Optional flags allow you to
customize the rule's properties.
"""
)
@Argument(help: "The name of the rule in PascalCase.")
var name: String
@Option(name: .long, help: "The rule's identifier. Defaults to the rule name in snake_case.")
var id: String?
@Option(name: .long, help: "Type of the rule.")
var kind: RuleKind = .lint
@Option(name: .long, help: "The rule's default severity.")
var severity: ViolationSeverity = .warning
@Flag(name: .long, help: "Indicates whether the rule shall be enabled by default.")
var `default` = false
@Flag(name: .long, help: "Indicates whether the rule is correctable.")
var correctable = false
@Flag(name: .long, help: "Indicates whether the rule has a custom rewriter.")
var rewriter = false
@Flag(name: .long, help: "Indicates whether the rule has a custom configuration.")
var config = false
@Flag(name: .long, help: "Indicates whether this rule has a dedicated test. This is usually not necessary.")
var test = false
@Flag(name: .long, help: "Indicates whether to overwrite existing files. Use with caution!")
var overwrite = false
func run() async throws {
let ruleDirectory = "Source/SwiftLintBuiltInRules/Rules"
let ruleLocation = "\(ruleDirectory)/\(kind.rawValue.capitalized)"
guard FileManager.default.fileExists(atPath: ruleLocation) else {
throw ValidationError("Command must be run from the root of the SwiftLint repository.")
}
print("Creating template(s) for new rule \"\(ruleName)\" identified by '\(ruleId)' ...")
let rulePath = "\(ruleLocation)/\(name)Rule.swift"
guard overwrite || !FileManager.default.fileExists(atPath: rulePath) else {
throw ValidationError("Rule file already exists at \(rulePath).")
}
try ruleTemplate.write(toFile: rulePath, atomically: true, encoding: .utf8)
print("Rule file created at \(rulePath).")
guard config else {
return
}
let configPath = "\(ruleDirectory)/RuleConfigurations/\(name)Configuration.swift"
guard overwrite || !FileManager.default.fileExists(atPath: configPath) else {
throw ValidationError("Configuration file already exists at \(configPath).")
}
try configTemplate.write(toFile: configPath, atomically: true, encoding: .utf8)
print("Configuration file created at \(configPath).")
guard test else {
return
}
let testDirectory = "Tests/BuiltInRulesTests"
let testPath = "\(testDirectory)/\(name)RuleTests.swift"
guard FileManager.default.fileExists(atPath: testDirectory) else {
throw ValidationError("Command must be run from the root of the SwiftLint repository.")
}
guard overwrite || !FileManager.default.fileExists(atPath: testPath) else {
throw ValidationError("Test file already exists at \(testPath).")
}
try testTemplate.write(toFile: testPath, atomically: true, encoding: .utf8)
print("Test file created at \(testPath).")
}
}
}
private extension SwiftLintDev.RuleTemplate {
var ruleId: String {
id ?? Self.camelCaseRegex.stringByReplacingMatches(
in: name,
range: NSRange(location: 0, length: name.utf16.count),
withTemplate: "_$0"
).lowercased()
}
var ruleName: String {
Self.camelCaseRegex.stringByReplacingMatches(
in: name,
range: NSRange(location: 0, length: name.utf16.count),
withTemplate: " $0"
)
}
var ruleTemplate: String {
var attributeArguments = [String]()
if rewriter {
attributeArguments.append("explicitRewriter: true")
}
if correctable, !rewriter {
attributeArguments.append("correctable: true")
}
attributeArguments.append("optIn: \(`default`)")
var ruleDescriptionArguments = [
"identifier: \"\(ruleId)\"",
"name: \"\(ruleName)\"",
"description: \"\"",
"kind: .\(kind.rawValue)",
"""
nonTriggeringExamples: [
Example(""),
]
""",
"""
triggeringExamples: [
Example(""),
]
""",
]
if correctable || rewriter {
ruleDescriptionArguments.append("""
corrections: [
Example(""):
Example(""),
]
""")
}
let configDecl = "var configuration = " + (
config
? "\(name)Configuration()"
: "SeverityConfiguration<Self>(.\(severity))"
)
var extensionContent = [
"""
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
}
""",
]
if rewriter {
extensionContent.append("""
final class Rewriter: ViolationsSyntaxRewriter<ConfigurationType> {
}
""")
}
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<Parent>(.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.
}
+12
View File
@@ -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,
]
)
}