From c4b6debbf5447240dead34be47b5e6294d0741d9 Mon Sep 17 00:00:00 2001 From: Edward Miniakhmetov Date: Mon, 2 Mar 2026 02:49:21 +0300 Subject: [PATCH 1/2] Add SonarQube generic import formatter --- Sources/Configuration/Configuration.swift | 4 + Sources/Configuration/OutputFormat.swift | 1 + .../Configuration/SonarQubeRuleSeverity.swift | 9 ++ .../Results/OutputFormatter.swift | 2 + .../Results/SonarQubeFormatter.swift | 148 ++++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 Sources/Configuration/SonarQubeRuleSeverity.swift create mode 100644 Sources/PeripheryKit/Results/SonarQubeFormatter.swift diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index 88aa09087..cabd10dc6 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: nil) + 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..6f4b9fe53 --- /dev/null +++ b/Sources/Configuration/SonarQubeRuleSeverity.swift @@ -0,0 +1,9 @@ +public enum SonarQubeRuleSeverity: String, CaseIterable { + case blocker = "BLOCKER" + case critical = "CRITICAL" + case major = "MAJOR" + case minor = "MINOR" + case info = "INFO" + + public static let `default`: Self = .info +} 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..0a0b300aa --- /dev/null +++ b/Sources/PeripheryKit/Results/SonarQubeFormatter.swift @@ -0,0 +1,148 @@ +import Configuration +import Foundation +import Logger +import SystemPackage + +final class SonarQubeFormatter: OutputFormatter { + let configuration: Configuration + lazy var ruleSeverity = configuration.sonarQubeRuleSeverity ?? .default + + let logger: Logger + + lazy var currentFilePath: FilePath = .current + + init(configuration: Configuration, logger: Logger) { + self.logger = logger + self.configuration = configuration + } + + // MARK: - Encodable Types for JSON + + private struct SonarQubeReport: Encodable { + let rules: Set + let issues: [Issue] + } + + private struct Rule: Encodable, Hashable { + let id: String + let name: String + let description: String + let engineId: String + let cleanCodeAttribute: String + let type: String + let severity: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + + private struct Issue: Encodable { + let ruleId: String + let primaryLocation: Location + let secondaryLocations: [Location]? + } + + private struct Location: Encodable { + let message: String + let filePath: String + let textRange: TextRange + } + + private struct TextRange: Encodable { + let startLine: Int + let startColumn: Int + + init(startLine: Int, startColumn: Int) { + self.startLine = startLine + // Column index for SonarQube report is expected to be 0 based. + self.startColumn = startColumn - 1 + } + } + + func format(_ results: [ScanResult], colored: Bool) throws -> String? { + var issues: [Issue] = [] + var rules: Set = [] + + for result in results { + let ruleId = describe(result.annotation) + + rules.insert( + .init( + id: ruleId, + name: result.annotation.name, + description: result.annotation.description, + engineId: "periphery", + cleanCodeAttribute: "EFFICIENT", + type: "CODE_SMELL", + severity: ruleSeverity.rawValue + ) + ) + + var descriptions = self.describe(result, colored: false) + guard !descriptions.isEmpty else { continue } + let primaryResult = descriptions.removeFirst() + let location = primaryResult.0 + let issue = Issue( + ruleId: ruleId, + primaryLocation: .init( + message: primaryResult.1, + filePath: outputPath(location).string, + textRange: .init( + startLine: location.line, + startColumn: location.column) + ), + secondaryLocations: descriptions.isEmpty ? nil : descriptions.map({ location, message in + .init( + message: message, + filePath: outputPath(location).string, + textRange: .init( + startLine: location.line, + startColumn: location.column + ) + ) + }) + ) + issues.append(issue) + } + + let report = SonarQubeReport(rules: rules, issues: issues) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + let data = try encoder.encode(report) + return String(data: 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" + } + } +} From 30ff4967b23aaeb01bb1908643161b0dc537ef4e Mon Sep 17 00:00:00 2001 From: Edward Miniakhmetov Date: Tue, 3 Mar 2026 16:39:51 +0300 Subject: [PATCH 2/2] Fixes and improvements --- Package.swift | 1 + Sources/Configuration/Configuration.swift | 6 +- .../Configuration/SonarQubeRuleSeverity.swift | 14 +- Sources/Frontend/Commands/ScanCommand.swift | 4 + .../Results/SonarQubeFormatter.swift | 127 ++++++------------ 5 files changed, 64 insertions(+), 88 deletions(-) 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 cabd10dc6..b4d46669c 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -152,8 +152,8 @@ public final class Configuration { @Setting(key: "bazel_check_visibility", defaultValue: false) public var bazelCheckVisibility: Bool - @Setting(key: "sonarqube_rule_severity", defaultValue: nil) - public var sonarQubeRuleSeverity: SonarQubeRuleSeverity? + @Setting(key: "sonarqube_rule_severity", defaultValue: .default, setter: { SonarQubeRuleSeverity(anyValue: $0) }) + public var sonarqubeRuleSeverity: SonarQubeRuleSeverity // Non user facing. public var guidedSetup: Bool = false @@ -228,7 +228,7 @@ public final class Configuration { $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, $relativeResults, $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $baseline, $writeBaseline, $writeResults, $genericProjectConfig, $bazel, $bazelFilter, $bazelIndexStore, $bazelCheckVisibility, - $sonarQubeRuleSeverity, + $sonarqubeRuleSeverity, ] private func buildFilenameMatchers(with patterns: [String]) -> [FilenameMatcher] { diff --git a/Sources/Configuration/SonarQubeRuleSeverity.swift b/Sources/Configuration/SonarQubeRuleSeverity.swift index 6f4b9fe53..e852b75dd 100644 --- a/Sources/Configuration/SonarQubeRuleSeverity.swift +++ b/Sources/Configuration/SonarQubeRuleSeverity.swift @@ -1,4 +1,6 @@ -public enum SonarQubeRuleSeverity: String, CaseIterable { +import ArgumentParser + +public enum SonarQubeRuleSeverity: String, CaseIterable, ExpressibleByArgument { case blocker = "BLOCKER" case critical = "CRITICAL" case major = "MAJOR" @@ -6,4 +8,14 @@ public enum SonarQubeRuleSeverity: String, CaseIterable { 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/SonarQubeFormatter.swift b/Sources/PeripheryKit/Results/SonarQubeFormatter.swift index 0a0b300aa..63c3dbb86 100644 --- a/Sources/PeripheryKit/Results/SonarQubeFormatter.swift +++ b/Sources/PeripheryKit/Results/SonarQubeFormatter.swift @@ -5,7 +5,6 @@ import SystemPackage final class SonarQubeFormatter: OutputFormatter { let configuration: Configuration - lazy var ruleSeverity = configuration.sonarQubeRuleSeverity ?? .default let logger: Logger @@ -16,102 +15,62 @@ final class SonarQubeFormatter: OutputFormatter { self.configuration = configuration } - // MARK: - Encodable Types for JSON - - private struct SonarQubeReport: Encodable { - let rules: Set - let issues: [Issue] - } - - private struct Rule: Encodable, Hashable { - let id: String - let name: String - let description: String - let engineId: String - let cleanCodeAttribute: String - let type: String - let severity: String - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - } - - private struct Issue: Encodable { - let ruleId: String - let primaryLocation: Location - let secondaryLocations: [Location]? - } - - private struct Location: Encodable { - let message: String - let filePath: String - let textRange: TextRange - } - - private struct TextRange: Encodable { - let startLine: Int - let startColumn: Int - - init(startLine: Int, startColumn: Int) { - self.startLine = startLine - // Column index for SonarQube report is expected to be 0 based. - self.startColumn = startColumn - 1 - } - } - func format(_ results: [ScanResult], colored: Bool) throws -> String? { - var issues: [Issue] = [] - var rules: Set = [] + var ruleIds: Set = [] + var rules: [Any] = [] + var issues: [Any] = [] for result in results { let ruleId = describe(result.annotation) - - rules.insert( - .init( - id: ruleId, - name: result.annotation.name, - description: result.annotation.description, - engineId: "periphery", - cleanCodeAttribute: "EFFICIENT", - type: "CODE_SMELL", - severity: ruleSeverity.rawValue - ) - ) + 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 - let issue = Issue( - ruleId: ruleId, - primaryLocation: .init( - message: primaryResult.1, - filePath: outputPath(location).string, - textRange: .init( - startLine: location.line, - startColumn: location.column) - ), - secondaryLocations: descriptions.isEmpty ? nil : descriptions.map({ location, message in - .init( - message: message, - filePath: outputPath(location).string, - textRange: .init( - startLine: location.line, - startColumn: location.column - ) - ) + 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 = SonarQubeReport(rules: rules, issues: issues) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] - let data = try encoder.encode(report) - return String(data: data, encoding: .utf8) + let report = [ + "rules": rules, + "issues": issues + ] + let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .withoutEscapingSlashes]) + return String(bytes: data, encoding: .utf8) } }