diff --git a/CHANGELOG.md b/CHANGELOG.md index b42f7fb..13718da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,14 @@ If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries se ### Security - None. +## [0.2.0] - 2020-04-10 +### Added +- Added new `-x` / `--xcode` option to print out warnings & errors in an Xcode-compatible manner to improve user experience when used with an Xcode build script. Requires `arguments: CommandLine.arguments` as parameters to `logSummary` in config file. + Issue: [#4](https://github.com/Flinesoft/AnyLint/issues/4) | PR: [#8](https://github.com/Flinesoft/AnyLint/pull/8) | Author: [Cihat Gündüz](https://github.com/Jeehut) + ## [0.1.1] - 2020-03-23 ### Added -- Added two simple lint check examples in first code sample in README. (Thanks for the pointer, [Dave Verwer](https://github.com/daveverwer)!) +- Added two simple lint check examples in first code sample in README. (Thanks for the pointer, [Dave Verwer](https://github.com/daveverwer)!) Author: [Cihat Gündüz](https://github.com/Jeehut) ### Changed - Changed `CheckInfo` id casing convention from snake_case to UpperCamelCase in `blank` template. diff --git a/Formula/anylint.rb b/Formula/anylint.rb index 9ba0049..c5deb69 100644 --- a/Formula/anylint.rb +++ b/Formula/anylint.rb @@ -1,7 +1,7 @@ class Anylint < Formula desc "Lint anything by combining the power of Swift & regular expressions" homepage "https://github.com/Flinesoft/AnyLint" - url "https://github.com/Flinesoft/AnyLint.git", :tag => "0.1.0", :revision => "25fec7dd29d86f0ef97fc2dddcd41d2576d9570c" + url "https://github.com/Flinesoft/AnyLint.git", :tag => "0.1.1", :revision => "e80ac907d160a0e8f359dc84fabfbd1cc80a8b50" head "https://github.com/Flinesoft/AnyLint.git" depends_on :xcode => ["11.3", :build] diff --git a/README.md b/README.md index ced2f50..876749d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

+ width=562 />

@@ -17,8 +17,8 @@ alt="Coverage"/> - Version: 0.1.1 + Version: 0.2.0 InstallationGetting StartedConfiguration + • Xcode Build ScriptDonationIssuesContributing @@ -86,11 +87,11 @@ To initialize AnyLint in a project, run: anylint --init blank ``` -This will create the Swift script file `lint.swift` with something like following contents: +This will create the Swift script file `lint.swift` with something like the following contents: ```swift #!/usr/local/bin/swift-sh -import AnyLint // @Flinesoft ~> 0.1.1 +import AnyLint // @Flinesoft ~> 0.2.0 // MARK: - Variables let readmeFile: Regex = #"README\.md"# @@ -120,7 +121,7 @@ try Lint.checkFileContents( ) // MARK: - Log Summary & Exit -Lint.logSummaryAndExit() +Lint.logSummaryAndExit(arguments: CommandLine.arguments) ``` The most important thing to note is that the **first two lines and the last line are required** for AnyLint to work properly. @@ -161,7 +162,7 @@ Many parameters in the above mentioned lint check methods are of `Regex` type. A 1. Using a **String**: ```swift - let regex = Regex(#"(foo|bar)[0-9]+"#) // => /(foo|bar)[0-9]+/` + let regex = Regex(#"(foo|bar)[0-9]+"#) // => /(foo|bar)[0-9]+/ ``` 2. Using a **String Literal**: ```swift @@ -188,6 +189,7 @@ While there is an initializer available, we recommend using a String Literal ins ```swift // accepted structure: (@): let checkInfo: CheckInfo = "ReadmePath: The README file should be named exactly `README.md`." +let checkInfoCustomSeverity: CheckInfo = "ReadmePath@warning: The README file should be named exactly `README.md`." ``` ### Check File Contents @@ -197,7 +199,7 @@ AnyLint has rich support for checking the contents of a file using a regex. The In its simplest form, the method just requires a `checkInfo` and a `regex`: ```swift -// MARK: empty_todo +// MARK: EmptyTodo try Lint.checkFileContents( checkInfo: "EmptyTodo: TODO comments should not be empty.", regex: #"// TODO: *\n"# @@ -308,7 +310,7 @@ When using the `customCheck`, you might want to also include some Swift packages ```swift #!/usr/local/bin/swift-sh -import AnyLint // @Flinesoft ~> 0.1.1 +import AnyLint // @Flinesoft ~> 0.2.0 import Files // @JohnSundell ~> 4.1.1 import ShellOut // @JohnSundell ~> 2.3.0 @@ -329,9 +331,23 @@ try Lint.customCheck(checkInfo: "Echo: Always say hello to the world.") { } // MARK: - Log Summary & Exit -Lint.logSummaryAndExit() +Lint.logSummaryAndExit(arguments: CommandLine.arguments) +``` + +## Xcode Build Script + +If you are using AnyLint for a project in Xcode, you can configure a build script to run it on each build. In order to do this select your target, choose the `Build Phases` tab and click the + button on the top left corner of that pane. Select `New Run Script Phase` and copy the following into the text box below the `Shell: /bin/sh` of your new run script phase: + +```shell +if which anylint > /dev/null; then + anylint -x +else + echo "warning: AnyLint not installed, download it from https://github.com/Flinesoft/AnyLint" +fi ``` +Next, make sure the AnyLint script runs before the steps `Compiling Sources` by moving it per drag & drop, for example right after `Dependencies`. You probably also want to rename it to somethng like `AnyLint`. + ## Donation AnyLint was brought to you by [Cihat Gündüz](https://github.com/Jeehut) in his free time. If you want to thank me and support the development of this project, please **make a small donation on [PayPal](https://paypal.me/Dschee/5EUR)**. In case you also like my other [open source contributions](https://github.com/Flinesoft) and [articles](https://medium.com/@Jeehut), please consider motivating me by **becoming a sponsor on [GitHub](https://github.com/sponsors/Jeehut)** or a **patron on [Patreon](https://www.patreon.com/Jeehut)**. diff --git a/Sources/AnyLint/Lint.swift b/Sources/AnyLint/Lint.swift index c6f2d27..16af2e4 100644 --- a/Sources/AnyLint/Lint.swift +++ b/Sources/AnyLint/Lint.swift @@ -125,12 +125,17 @@ public enum Lint { } /// Logs the summary of all detected violations and exits successfully on no violations or with a failure, if any violations. - public static func logSummaryAndExit(failOnWarnings: Bool = false) { + public static func logSummaryAndExit(failOnWarnings: Bool = false, arguments: [String] = []) { + let targetIsXcode = arguments.contains(Logger.OutputType.xcode.rawValue) + if targetIsXcode { + log = Logger(outputType: .xcode) + } + Statistics.shared.logSummary() - if Statistics.shared.violationsBySeverity[.error]!.isFilled { + if Statistics.shared.violations(severity: .error, excludeAutocorrected: targetIsXcode).isFilled { log.exit(status: .failure) - } else if failOnWarnings && Statistics.shared.violationsBySeverity[.warning]!.isFilled { + } else if failOnWarnings && Statistics.shared.violations(severity: .warning, excludeAutocorrected: targetIsXcode).isFilled { log.exit(status: .failure) } else { log.exit(status: .success) diff --git a/Sources/AnyLint/Severity.swift b/Sources/AnyLint/Severity.swift index 2ece7ea..195c8a0 100644 --- a/Sources/AnyLint/Severity.swift +++ b/Sources/AnyLint/Severity.swift @@ -41,3 +41,9 @@ public enum Severity: Int, CaseIterable { } } } + +extension Severity: Comparable { + public static func < (lhs: Severity, rhs: Severity) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Sources/AnyLint/Statistics.swift b/Sources/AnyLint/Statistics.swift index 3ef9dfa..d917703 100644 --- a/Sources/AnyLint/Statistics.swift +++ b/Sources/AnyLint/Statistics.swift @@ -31,55 +31,85 @@ final class Statistics { if executedChecks.isEmpty { log.message("No checks found to perform.", level: .warning) } else if violationsBySeverity.values.contains(where: { $0.isFilled }) { - for check in executedChecks { - if let checkViolations = violationsPerCheck[check], checkViolations.isFilled { - let violationsWithLocationMessage = checkViolations.filter { $0.locationMessage() != nil } - - if violationsWithLocationMessage.isFilled { - log.message( - "\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s) at:", - level: check.severity.logLevel - ) - let numerationDigits = String(violationsWithLocationMessage.count).count - - for (index, violation) in violationsWithLocationMessage.enumerated() { - let violationNumString = String(format: "%0\(numerationDigits)d", index + 1) - let prefix = "> \(violationNumString). " - log.message(prefix + violation.locationMessage()!, level: check.severity.logLevel) - - let prefixLengthWhitespaces = (0 ..< prefix.count).map { _ in " " }.joined() - if let appliedAutoCorrection = violation.appliedAutoCorrection { - for messageLine in appliedAutoCorrection.appliedMessageLines { - log.message(prefixLengthWhitespaces + messageLine, level: .info) - } - } else if let matchedString = violation.matchedString { - log.message(prefixLengthWhitespaces + "Matching string:".bold + " (trimmed & reduced whitespaces)", level: .info) - let matchedStringOutput = matchedString - .showNewlines() - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: " ", with: " ") - log.message(prefixLengthWhitespaces + "> " + matchedStringOutput, level: .info) + switch log.outputType { + case .console, .test: + logViolationsToConsole() + + case .xcode: + showViolationsInXcode() + } + } else { + log.message("Performed \(executedChecks.count) check(s) without any violations.", level: .success) + } + } + + func violations(severity: Severity, excludeAutocorrected: Bool) -> [Violation] { + let violations: [Violation] = violationsBySeverity[severity]! + guard excludeAutocorrected else { return violations } + return violations.filter { $0.appliedAutoCorrection == nil } + } + + private func logViolationsToConsole() { + for check in executedChecks { + if let checkViolations = violationsPerCheck[check], checkViolations.isFilled { + let violationsWithLocationMessage = checkViolations.filter { $0.locationMessage(pathType: .relative) != nil } + + if violationsWithLocationMessage.isFilled { + log.message( + "\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s) at:", + level: check.severity.logLevel + ) + let numerationDigits = String(violationsWithLocationMessage.count).count + + for (index, violation) in violationsWithLocationMessage.enumerated() { + let violationNumString = String(format: "%0\(numerationDigits)d", index + 1) + let prefix = "> \(violationNumString). " + log.message(prefix + violation.locationMessage(pathType: .relative)!, level: check.severity.logLevel) + + let prefixLengthWhitespaces = (0 ..< prefix.count).map { _ in " " }.joined() + if let appliedAutoCorrection = violation.appliedAutoCorrection { + for messageLine in appliedAutoCorrection.appliedMessageLines { + log.message(prefixLengthWhitespaces + messageLine, level: .info) } + } else if let matchedString = violation.matchedString { + log.message(prefixLengthWhitespaces + "Matching string:".bold + " (trimmed & reduced whitespaces)", level: .info) + let matchedStringOutput = matchedString + .showNewlines() + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: " ", with: " ") + log.message(prefixLengthWhitespaces + "> " + matchedStringOutput, level: .info) } - } else { - log.message("\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s).", level: check.severity.logLevel) } - - log.message(">> Hint: \(check.hint)".bold.italic, level: check.severity.logLevel) + } else { + log.message("\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s).", level: check.severity.logLevel) } + + log.message(">> Hint: \(check.hint)".bold.italic, level: check.severity.logLevel) } + } - let errors = "\(violationsBySeverity[.error]!.count) error(s)" - let warnings = "\(violationsBySeverity[.warning]!.count) warning(s)" + let errors = "\(violationsBySeverity[.error]!.count) error(s)" + let warnings = "\(violationsBySeverity[.warning]!.count) warning(s)" - log.message( - "Performed \(executedChecks.count) check(s) and found \(errors) & \(warnings).", - level: maxViolationSeverity!.logLevel - ) - } else { - log.message("Performed \(executedChecks.count) check(s) without any violations.", level: .success) + log.message( + "Performed \(executedChecks.count) check(s) and found \(errors) & \(warnings).", + level: maxViolationSeverity!.logLevel + ) + } + + private func showViolationsInXcode() { + for severity in violationsBySeverity.keys.sorted().reversed() { + let severityViolations = violationsBySeverity[severity]! + for violation in severityViolations where violation.appliedAutoCorrection == nil { + let check = violation.checkInfo + log.xcodeMessage( + "[\(check.id)] \(check.hint)", + level: check.severity.logLevel, + location: violation.locationMessage(pathType: .absolute) + ) + } } } } diff --git a/Sources/AnyLint/Violation.swift b/Sources/AnyLint/Violation.swift index b1f9c64..5105a51 100644 --- a/Sources/AnyLint/Violation.swift +++ b/Sources/AnyLint/Violation.swift @@ -33,9 +33,9 @@ public struct Violation { self.appliedAutoCorrection = appliedAutoCorrection } - func locationMessage() -> String? { + func locationMessage(pathType: String.PathType) -> String? { guard let filePath = filePath else { return nil } - guard let locationInfo = locationInfo else { return filePath } - return "\(filePath):\(locationInfo.line):\(locationInfo.charInLine)" + guard let locationInfo = locationInfo else { return filePath.path(type: pathType) } + return "\(filePath.path(type: pathType)):\(locationInfo.line):\(locationInfo.charInLine):" } } diff --git a/Sources/AnyLintCLI/Commands/SingleCommand.swift b/Sources/AnyLintCLI/Commands/SingleCommand.swift index 160f2d5..9483cfd 100644 --- a/Sources/AnyLintCLI/Commands/SingleCommand.swift +++ b/Sources/AnyLintCLI/Commands/SingleCommand.swift @@ -11,6 +11,9 @@ class SingleCommand: Command { @Flag("-v", "--version", description: "Print the current tool version") var version: Bool + @Flag("-x", "--xcode", description: "Print warnings & errors in a format to be reported right within Xcodes left sidebar") + var xcode: Bool + @Key("-i", "--init", description: "Configure AnyLint with a default template. Has to be one of: [\(CLIConstants.initTemplateCases)]") var initTemplateName: String? @@ -20,6 +23,10 @@ class SingleCommand: Command { // MARK: - Execution func execute() throws { + if xcode { + log = Logger(outputType: .xcode) + } + // version subcommand if version { try VersionTask().perform() diff --git a/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift b/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift index 9860e90..352a165 100644 --- a/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift +++ b/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift @@ -11,6 +11,6 @@ extension ConfigurationTemplate { } static var commonSuffix: String { - "\n\n// MARK: - Log Summary & Exit\nLint.logSummaryAndExit()\n" + "\n\n// MARK: - Log Summary & Exit\nLint.logSummaryAndExit(arguments: CommandLine.arguments)\n" } } diff --git a/Sources/AnyLintCLI/Tasks/LintTask.swift b/Sources/AnyLintCLI/Tasks/LintTask.swift index c4f6703..a77f541 100644 --- a/Sources/AnyLintCLI/Tasks/LintTask.swift +++ b/Sources/AnyLintCLI/Tasks/LintTask.swift @@ -23,10 +23,13 @@ extension LintTask: TaskHandler { do { log.message("Start linting using config file at \(configFilePath) ...", level: .info) - try Task.run(bash: "\(configFilePath.absolutePath)") + try Task.run(bash: "\(configFilePath.absolutePath) \(log.outputType.rawValue)") log.message("Linting successful using config file at \(configFilePath). Congrats! 🎉", level: .success) } catch is RunError { - log.message("Linting failed using config file at \(configFilePath).", level: .error) + if log.outputType != .xcode { + log.message("Linting failed using config file at \(configFilePath).", level: .error) + } + throw LintError.configFileFailed } } diff --git a/Sources/Utility/Constants.swift b/Sources/Utility/Constants.swift index f1adc14..347b371 100644 --- a/Sources/Utility/Constants.swift +++ b/Sources/Utility/Constants.swift @@ -9,7 +9,7 @@ public var log = Logger(outputType: .console) /// Constants to reference across the project. public enum Constants { /// The current tool version string. Conforms to SemVer 2.0. - public static let currentVersion: String = "0.1.1" + public static let currentVersion: String = "0.2.0" /// The name of this tool. public static let toolName: String = "AnyLint" diff --git a/Sources/Utility/Extensions/StringExt.swift b/Sources/Utility/Extensions/StringExt.swift index 8be934a..d1ec81a 100644 --- a/Sources/Utility/Extensions/StringExt.swift +++ b/Sources/Utility/Extensions/StringExt.swift @@ -1,15 +1,25 @@ import Foundation extension String { + /// The type of a given file path. + public enum PathType { + /// The relative path. + case relative + + /// The absolute path. + case absolute + } + /// Returns the absolute path for a path given relative to the current directory. public var absolutePath: String { - guard let url = URL(string: self, relativeTo: fileManager.currentDirectoryUrl) else { - log.message("Could not convert path '\(self)' to type URL.", level: .error) - log.exit(status: .failure) - return "" // only reachable in unit tests - } + guard !self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } + return fileManager.currentDirectoryUrl.appendingPathComponent(self).path + } - return url.absoluteString + /// Returns the relative path for a path given relative to the current directory. + public var relativePath: String { + guard self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } + return replacingOccurrences(of: fileManager.currentDirectoryUrl.path, with: "") } /// Returns the parent directory path. @@ -23,6 +33,17 @@ extension String { return url.deletingLastPathComponent().absoluteString } + /// Returns the path with the given type related to the current directory. + public func path(type: PathType) -> String { + switch type { + case .absolute: + return absolutePath + + case .relative: + return relativePath + } + } + /// Returns the path with a components appended at it. public func appendingPathComponent(_ pathComponent: String) -> String { guard let pathUrl = URL(string: self) else { diff --git a/Sources/Utility/Logger.swift b/Sources/Utility/Logger.swift index d8a42ab..e970074 100644 --- a/Sources/Utility/Logger.swift +++ b/Sources/Utility/Logger.swift @@ -35,10 +35,13 @@ public final class Logger { } /// The output type. - public enum OutputType { + public enum OutputType: String { /// Output is targeted to a console to be read by developers. case console + /// Output is targeted to Xcodes left pane to be interpreted by it to mark errors & warnings. + case xcode + /// Output is targeted for unit tests. Collect into globally accessible TestHelper. case test } @@ -62,9 +65,11 @@ public final class Logger { } } - let outputType: OutputType + /// The output type of the logger. + public let outputType: OutputType - init(outputType: OutputType) { + /// Initializes a new Logger object with a given output type. + public init(outputType: OutputType) { self.outputType = outputType } @@ -78,6 +83,9 @@ public final class Logger { case .console: consoleMessage(message, level: level) + case .xcode: + xcodeMessage(message, level: level) + case .test: TestHelper.shared.consoleOutputs.append((message, level)) } @@ -86,7 +94,7 @@ public final class Logger { /// Exits the current program with the given status. public func exit(status: ExitStatus) { switch outputType { - case .console: + case .console, .xcode: Darwin.exit(status.statusCode) case .test: @@ -110,6 +118,20 @@ public final class Logger { } } + /// Reports a message in an Xcode compatible format to be shown in the left pane. + /// + /// - Parameters: + /// - message: The message to be printed. Don't include `Error!`, `Warning!` or similar information at the beginning. + /// - level: The level of the print statement. + /// - location: The file, line and char in line location string. + public func xcodeMessage(_ message: String, level: PrintLevel, location: String? = nil) { + if let location = location { + print("\(location) \(level.rawValue): \(Constants.toolName): \(message)") + } else { + print("\(level.rawValue): \(Constants.toolName): \(message)") + } + } + private func formattedCurrentTime() -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss.SSS" diff --git a/Tests/AnyLintTests/StatisticsTests.swift b/Tests/AnyLintTests/StatisticsTests.swift index b85df9d..5a44bcb 100644 --- a/Tests/AnyLintTests/StatisticsTests.swift +++ b/Tests/AnyLintTests/StatisticsTests.swift @@ -102,9 +102,9 @@ final class StatisticsTests: XCTestCase { "> 2. Hogwarts/Albus.swift", ">> Hint: hint2".bold.italic, "\("[id3]".bold) Found 3 violation(s) at:", - "> 1. Hogwarts/Harry.swift:10:30", - "> 2. Hogwarts/Harry.swift:72:17", - "> 3. Hogwarts/Albus.swift:40:4", + "> 1. Hogwarts/Harry.swift:10:30:", + "> 2. Hogwarts/Harry.swift:72:17:", + "> 3. Hogwarts/Albus.swift:40:4:", ">> Hint: hint3".bold.italic, "Performed 3 check(s) and found 3 error(s) & 2 warning(s).", ] diff --git a/Tests/AnyLintTests/ViolationTests.swift b/Tests/AnyLintTests/ViolationTests.swift index 96e4a7d..c9d0ebd 100644 --- a/Tests/AnyLintTests/ViolationTests.swift +++ b/Tests/AnyLintTests/ViolationTests.swift @@ -12,10 +12,10 @@ final class ViolationTests: XCTestCase { func testLocationMessage() { let checkInfo = CheckInfo(id: "demo_check", hint: "Make sure to always check the demo.", severity: .warning) - XCTAssertNil(Violation(checkInfo: checkInfo).locationMessage()) + XCTAssertNil(Violation(checkInfo: checkInfo).locationMessage(pathType: .relative)) let fileViolation = Violation(checkInfo: checkInfo, filePath: "Temp/Souces/Hello.swift") - XCTAssertEqual(fileViolation.locationMessage(), "Temp/Souces/Hello.swift") + XCTAssertEqual(fileViolation.locationMessage(pathType: .relative), "Temp/Souces/Hello.swift") let locationInfoViolation = Violation( checkInfo: checkInfo, @@ -23,6 +23,6 @@ final class ViolationTests: XCTestCase { locationInfo: String.LocationInfo(line: 5, charInLine: 15) ) - XCTAssertEqual(locationInfoViolation.locationMessage(), "Temp/Souces/World.swift:5:15") + XCTAssertEqual(locationInfoViolation.locationMessage(pathType: .relative), "Temp/Souces/World.swift:5:15:") } }