diff --git a/Package.swift b/Package.swift index 9792e3ce8..17f054ab0 100644 --- a/Package.swift +++ b/Package.swift @@ -53,6 +53,7 @@ var targets: [PackageDescription.Target] = [ .product(name: "Yams", package: "Yams"), .product(name: "SystemPackage", package: "swift-system"), .product(name: "FilenameMatcher", package: "swift-filename-matcher"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), .target( diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index 88aa09087..b4d46669c 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -152,6 +152,9 @@ public final class Configuration { @Setting(key: "bazel_check_visibility", defaultValue: false) public var bazelCheckVisibility: Bool + @Setting(key: "sonarqube_rule_severity", defaultValue: .default, setter: { SonarQubeRuleSeverity(anyValue: $0) }) + public var sonarqubeRuleSeverity: SonarQubeRuleSeverity + // Non user facing. public var guidedSetup: Bool = false public var projectRoot: FilePath = .init() @@ -225,6 +228,7 @@ public final class Configuration { $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, $relativeResults, $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $baseline, $writeBaseline, $writeResults, $genericProjectConfig, $bazel, $bazelFilter, $bazelIndexStore, $bazelCheckVisibility, + $sonarqubeRuleSeverity, ] private func buildFilenameMatchers(with patterns: [String]) -> [FilenameMatcher] { diff --git a/Sources/Configuration/OutputFormat.swift b/Sources/Configuration/OutputFormat.swift index 120ece70e..4b34f395f 100644 --- a/Sources/Configuration/OutputFormat.swift +++ b/Sources/Configuration/OutputFormat.swift @@ -9,6 +9,7 @@ public enum OutputFormat: String, CaseIterable { case githubActions = "github-actions" case githubMarkdown = "github-markdown" case gitlabCodeQuality = "gitlab-codequality" + case sonarQube = "sonarqube" public static let `default` = OutputFormat.xcode diff --git a/Sources/Configuration/SonarQubeRuleSeverity.swift b/Sources/Configuration/SonarQubeRuleSeverity.swift new file mode 100644 index 000000000..e852b75dd --- /dev/null +++ b/Sources/Configuration/SonarQubeRuleSeverity.swift @@ -0,0 +1,21 @@ +import ArgumentParser + +public enum SonarQubeRuleSeverity: String, CaseIterable, ExpressibleByArgument { + case blocker = "BLOCKER" + case critical = "CRITICAL" + case major = "MAJOR" + case minor = "MINOR" + case info = "INFO" + + public static let `default`: Self = .info + + init?(anyValue: Any) { + if let option = anyValue as? Self { + self = option + return + } + guard let stringValue = anyValue as? String else { return nil } + + self.init(rawValue: stringValue) + } +} diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 8ac9efbb5..3bf889316 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -162,6 +162,9 @@ struct ScanCommand: ParsableCommand { @Flag(help: "Enable Bazel visibility checking") var bazelCheckVisibility: Bool = defaultConfiguration.$bazelCheckVisibility.defaultValue + @Option(help: "SonarQube Rule severity") + var sonarqubeRuleSeverity: SonarQubeRuleSeverity = .default + private static let defaultConfiguration = Configuration() func run() throws { @@ -223,6 +226,7 @@ struct ScanCommand: ParsableCommand { configuration.apply(\.$bazelFilter, bazelFilter) configuration.apply(\.$bazelIndexStore, bazelIndexStore) configuration.apply(\.$bazelCheckVisibility, bazelCheckVisibility) + configuration.apply(\.$sonarqubeRuleSeverity, sonarqubeRuleSeverity) configuration.buildFilenameMatchers() diff --git a/Sources/PeripheryKit/Results/OutputFormatter.swift b/Sources/PeripheryKit/Results/OutputFormatter.swift index 6bdf99af1..59ae473cf 100644 --- a/Sources/PeripheryKit/Results/OutputFormatter.swift +++ b/Sources/PeripheryKit/Results/OutputFormatter.swift @@ -143,6 +143,8 @@ public extension OutputFormat { GitHubMarkdownFormatter.self case .gitlabCodeQuality: GitLabCodeQualityFormatter.self + case .sonarQube: + SonarQubeFormatter.self } } } diff --git a/Sources/PeripheryKit/Results/SonarQubeFormatter.swift b/Sources/PeripheryKit/Results/SonarQubeFormatter.swift new file mode 100644 index 000000000..63c3dbb86 --- /dev/null +++ b/Sources/PeripheryKit/Results/SonarQubeFormatter.swift @@ -0,0 +1,107 @@ +import Configuration +import Foundation +import Logger +import SystemPackage + +final class SonarQubeFormatter: OutputFormatter { + let configuration: Configuration + + let logger: Logger + + lazy var currentFilePath: FilePath = .current + + init(configuration: Configuration, logger: Logger) { + self.logger = logger + self.configuration = configuration + } + + func format(_ results: [ScanResult], colored: Bool) throws -> String? { + var ruleIds: Set = [] + var rules: [Any] = [] + var issues: [Any] = [] + + for result in results { + let ruleId = describe(result.annotation) + if !ruleIds.contains(ruleId) { + ruleIds.insert(ruleId) + rules.append([ + "id": ruleId, + "name": result.annotation.name, + "description": result.annotation.description, + "engineId": "periphery", + "cleanCodeAttribute": "EFFICIENT", + "type": "CODE_SMELL", + "severity": configuration.sonarqubeRuleSeverity.rawValue + ]) + } + + var descriptions = self.describe(result, colored: false) + guard !descriptions.isEmpty else { continue } + let primaryResult = descriptions.removeFirst() + let location = primaryResult.0 + var issue: [String: Any] = [ + "ruleId": ruleId, + "primaryLocation": [ + "message": primaryResult.1, + "filePath": outputPath(location).string, + "textRange": [ + "startLine": location.line, + "startColumn": max(0, location.column - 1) + ] + ] + ] + if !descriptions.isEmpty { + issue["secondaryLocations"] = descriptions.map({ location, message in + [ + "message": message, + "filePath": outputPath(location).string, + "textRange": [ + "startLine": location.line, + "startColumn": max(0, location.column - 1) + ] + ] + }) + } + issues.append(issue) + } + + let report = [ + "rules": rules, + "issues": issues + ] + let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .withoutEscapingSlashes]) + return String(bytes: data, encoding: .utf8) + } +} + +private extension ScanResult.Annotation { + var name: String { + switch self { + case .unused: + "Unused Declaration" + case .assignOnlyProperty: + "Assign-only Property" + case .redundantProtocol: + "Redundant Protocol" + case .redundantPublicAccessibility: + "Redundant Public Accessibility" + case .superfluousIgnoreCommand: + "Superfluous Ignore Comment" + } + } + + var description: String { + switch self { + case .unused: + "Unused Declaration" + case .assignOnlyProperty: + "Property is assigned but never used" + case .redundantProtocol: + "Protocol is never used as an existential type" + case .redundantPublicAccessibility: + "Public accessibility is redundant as declaration is not used outside its module" + case .superfluousIgnoreCommand: + "Ignore comment is unnecessary as declaration is referenced" + } + } +}