Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache implementation #1073

Merged
merged 40 commits into from
Jan 11, 2017
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
c698d02
Initial cache implementation
marcelofabri Dec 27, 2016
a92a24c
Ignore cache when versions are different
marcelofabri Dec 27, 2016
f1e3376
Fixing compile isse
marcelofabri Dec 27, 2016
b1f62e2
Add some tests
marcelofabri Dec 27, 2016
9d42a73
Add more tests
marcelofabri Dec 27, 2016
a47385a
Ignore cache if configuration has changed
marcelofabri Dec 27, 2016
77e4249
Improved cache structure
marcelofabri Dec 27, 2016
34af12a
Temporary fix for getting version on SPM
marcelofabri Dec 27, 2016
3d37624
Moving cache usage to the linter
marcelofabri Dec 28, 2016
c7461e0
Supporting CLI and configuration options
marcelofabri Dec 28, 2016
b006a28
Merge remote-tracking branch 'upstream/master' into cache
marcelofabri Dec 31, 2016
1ef461b
Merge remote-tracking branch 'upstream/master' into cache
marcelofabri Dec 31, 2016
a2ea9ca
Merge remote-tracking branch 'upstream/master' into cache
marcelofabri Dec 31, 2016
b3cd500
Make LinterCache final
marcelofabri Dec 31, 2016
a56dba7
Move cache code to an extension
marcelofabri Dec 31, 2016
b9eb54a
Merge remote-tracking branch 'upstream/master' into cache
marcelofabri Jan 2, 2017
119c214
Merge remote-tracking branch 'upstream/master' into cache
marcelofabri Jan 2, 2017
e8ead88
Make LinterCache thread-safe
marcelofabri Jan 2, 2017
8af6552
Merge branch 'master' into cache
marcelofabri Jan 4, 2017
caee985
Generate Version.swift from make set_version
marcelofabri Jan 5, 2017
0cf4338
PR feedback
marcelofabri Jan 5, 2017
903f43b
Merge branch 'master' into cache
marcelofabri Jan 5, 2017
b1b9027
Merge branch 'master' into cache
marcelofabri Jan 6, 2017
f89068e
Merge branch 'master' into cache
marcelofabri Jan 7, 2017
20620d5
Merge branch 'master' into cache
marcelofabri Jan 7, 2017
1d4d04d
PR feedback
marcelofabri Jan 7, 2017
05d0a31
Merge branch 'master' into cache
marcelofabri Jan 10, 2017
5e30935
Save cache in shared folder by default
marcelofabri Jan 10, 2017
e1e2369
Merge branch 'master' into cache
marcelofabri Jan 10, 2017
94f48f4
PR feedback
marcelofabri Jan 10, 2017
b7b905c
Merge branch 'master' into cache
marcelofabri Jan 10, 2017
c28e308
Get hash from parsed dictionary instead of String
marcelofabri Jan 10, 2017
66d0bb2
Update how hash is calculated
marcelofabri Jan 10, 2017
2a97a1c
PR feedback
marcelofabri Jan 10, 2017
0530a23
Merge branch 'master' into cache
jpsim Jan 10, 2017
4a9806f
disable caching when running OSSCheck
jpsim Jan 10, 2017
86214a7
remove trailing comma from array
jpsim Jan 10, 2017
41251d8
run LinterCacheTests on Linux
jpsim Jan 10, 2017
b189da0
add changelog entry for caching
jpsim Jan 10, 2017
5702ef3
disable caching, handle errors & retry if can't build for OSSCheck
jpsim Jan 10, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ get_version:

set_version:
$(eval NEW_VERSION := $(filter-out $@,$(MAKECMDGOALS)))
sed 's/__VERSION__/$(NEW_VERSION)/g' script/Version.swift.template > Source/SwiftLintFramework/Models/Version.swift
@/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $(NEW_VERSION)" "$(SWIFTLINTFRAMEWORK_PLIST)"
@/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $(NEW_VERSION)" "$(SWIFTLINT_PLIST)"

Expand Down
15 changes: 11 additions & 4 deletions Source/SwiftLintFramework/Models/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ private enum ConfigurationKey: String {
case useNestedConfigs = "use_nested_configs" // deprecated
case whitelistRules = "whitelist_rules"
case warningThreshold = "warning_threshold"
case cachePath = "cache_path"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the rest of this list is sorted

}

public struct Configuration: Equatable {
Expand All @@ -32,6 +33,8 @@ public struct Configuration: Equatable {
public let rules: [Rule]
public var rootPath: String? // the root path to search for nested configurations
public var configurationPath: String? // if successfully loaded from a path
public var hash: Int?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make Configuration conform to Hashable? http://swiftdoc.org/v3.0/protocol/Hashable/

Would require naming this variable as hashValue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mainly because I thought it'd be too much work to make Rule conform to Hashable as well. Do you think it's worth the extra complexity?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Rule is uniquely identified by its identifier, so its hashValue can just be identifier.hashValue and this can be implemented as a protocol extension.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about its configuration?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, ok thinking more about it, Hashable implies Equatable, and the protocol semantics require that all user-visible data from two equal types be indistinguishable, so makes sense not to conform to it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, to make sure we're in the same page, do you still think we should make Configuration conform to Hashable?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No.

public let cachePath: String?

public init?(disabledRules: [String] = [],
optInRules: [String] = [],
Expand All @@ -41,11 +44,12 @@ public struct Configuration: Equatable {
warningThreshold: Int? = nil,
reporter: String = XcodeReporter.identifier,
ruleList: RuleList = masterRuleList,
configuredRules: [Rule]? = nil) {

configuredRules: [Rule]? = nil,
cachePath: String? = nil) {
self.included = included
self.excluded = excluded
self.reporter = reporter
self.cachePath = cachePath

let configuredRules = configuredRules
?? (try? ruleList.configuredRules(with: [:]))
Expand Down Expand Up @@ -134,7 +138,8 @@ public struct Configuration: Equatable {
reporter: dict[ConfigurationKey.reporter.rawValue] as? String ??
XcodeReporter.identifier,
ruleList: ruleList,
configuredRules: configuredRules)
configuredRules: configuredRules,
cachePath: dict[ConfigurationKey.cachePath.rawValue] as? String)
}

public init(path: String = Configuration.fileName, rootPath: String? = nil,
Expand All @@ -157,6 +162,7 @@ public struct Configuration: Equatable {
}
self.init(dict: dict)!
configurationPath = fullPath
hash = yamlContents.hash
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of hashing the yaml contents, we could hash the properties of this struct, meaning that changes to the configuration file like reordering items or adding comments wouldn't invalidate the cache.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would also allow Configuration to conform to Hashable and for caching to work with configurations created without a YAML source.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget the bit about conforming to Hashable, but do you think you could adapt this to use the has value from dict at least? Would allow writing comments and reordering YAML contents without throwing away the cache.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in c28e308, but had to bridge to NSDictionary

self.rootPath = rootPath
return
} catch YamlParserError.yamlParsing(let message) {
Expand Down Expand Up @@ -242,7 +248,8 @@ private func validKeys(ruleList: RuleList) -> [String] {
.reporter,
.useNestedConfigs,
.warningThreshold,
.whitelistRules
.whitelistRules,
.cachePath
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list is also otherwise alphabetized.

].map({ $0.rawValue }) + ruleList.allValidIdentifiers()
}

Expand Down
39 changes: 38 additions & 1 deletion Source/SwiftLintFramework/Models/Linter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ extension Rule {
public struct Linter {
public let file: File
private let rules: [Rule]
private let cache: LinterCache?

public var styleViolations: [StyleViolation] {
return getStyleViolations().0
Expand All @@ -64,6 +65,11 @@ public struct Linter {
}

private func getStyleViolations(_ benchmark: Bool = false) -> ([StyleViolation], [(id: String, time: Double)]) {

if let cached = cachedStyleViolations(benchmark) {
return cached
}

if file.sourcekitdFailed {
queuedPrintError("Most rules will be skipped because sourcekitd has failed.")
}
Expand All @@ -78,6 +84,11 @@ public struct Linter {
deprecatedToValidIdentifier[key] = value
}

if let cache = cache, let file = file.path {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let path = file.path would disambiguate from the property

let hash = self.file.contents.hash
cache.cacheFile(file, violations: violations, hash: hash)
}

for (deprecatedIdentifier, identifier) in deprecatedToValidIdentifier {
queuedPrintError("'\(deprecatedIdentifier)' rule has been renamed to '\(identifier)' and will be " +
"completely removed in a future release.")
Expand All @@ -86,8 +97,34 @@ public struct Linter {
return (violations, ruleTimes)
}

public init(file: File, configuration: Configuration = Configuration()!) {
private func cachedStyleViolations(_ benchmark: Bool = false) -> ([StyleViolation], [(id: String, time: Double)])? {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

benchmark should be a required argument label to conform to Swift 3 API Design Guidelines.

let start: Date! = benchmark ? Date() : nil
guard let cache = cache,
let file = file.path,
case let hash = self.file.contents.hash,
let cachedViolations = cache.violations(for: file, hash: hash) else {
return nil
}

var ruleTimes = [(id: String, time: Double)]()
if benchmark {
// let's assume that all rules should have the same duration and split the duration among them
let totalTime = -start.timeIntervalSinceNow
let fractionedTime = totalTime / TimeInterval(rules.count)
ruleTimes = rules.flatMap { rule in
let id = type(of: rule).description.identifier
return (id, fractionedTime)
}
}

return (cachedViolations, ruleTimes)
}

public init(file: File,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this declaration doesn't need to be broken up in multiple lines.

configuration: Configuration = Configuration()!,
cache: LinterCache? = nil) {
self.file = file
self.cache = cache
rules = configuration.rules
}

Expand Down
125 changes: 125 additions & 0 deletions Source/SwiftLintFramework/Models/LinterCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//
// LinterCache.swift
// SwiftLint
//
// Created by Marcelo Fabri on 12/27/16.
// Copyright © 2016 Realm. All rights reserved.
//

import Foundation
import SourceKittenFramework

public enum LinterCacheError: Error {
case invalidFormat
case differentVersion
case differentConfiguration
}

public final class LinterCache {
private var cache: [String: Any]
private let lock = NSLock()

public init(currentVersion: Version = .current, configurationHash: Int? = nil) {
cache = [String: Any]()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just this?

cache = [
    "version": currentVersion.value,
    "configuration_hash: configurationHash,
    "files": [:]
]

cache["version"] = currentVersion.value
cache["configuration_hash"] = configurationHash
cache["files"] = [:]
}

public init(cache: Any, currentVersion: Version = .current, configurationHash: Int? = nil) throws {
guard let dictionary = cache as? [String: Any] else {
throw LinterCacheError.invalidFormat
}

guard let version = dictionary["version"] as? String, version == currentVersion.value else {
throw LinterCacheError.differentVersion
}

if dictionary["configuration_hash"] as? Int != configurationHash {
throw LinterCacheError.differentConfiguration
}

self.cache = dictionary
}

public convenience init(contentsOf url: URL, currentVersion: Version = .current,
configurationHash: Int? = nil) throws {
let data = try Data(contentsOf: url)
let json = try JSONSerialization.jsonObject(with: data, options: [])
try self.init(cache: json, currentVersion: currentVersion,
configurationHash: configurationHash)
}

public func cacheFile(_ file: String, violations: [StyleViolation], hash: Int) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't conform to Swift 3 API Design Guidelines, and implementation could be simplified:

public func cache(violations: [StyleViolation], forFile file: String, fileHash: Int) {
    lock.lock()
    var filesCache = (cache["files"] as? [String: Any]) ?? [:]
    filesCache[file] = [
        "violations": violations.map(dictionaryForViolation),
        "hash": fileHash
    ]
    cache["files"] = filesCache
    lock.unlock()
}

Adds an insignificant amount of work inside the lock.

var entry = [String: Any]()
var fileViolations = entry["violations"] as? [[String: Any]] ?? []

for violation in violations {
fileViolations.append(dictionaryForViolation(violation))
}

entry["violations"] = fileViolations
entry["hash"] = hash

lock.lock()
var filesCache = (cache["files"] as? [String: Any]) ?? [:]
filesCache[file] = entry
cache["files"] = filesCache
lock.unlock()
}

public func violations(for file: String, hash: Int) -> [StyleViolation]? {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weakly typed argument. Label should be forFile file: String.

lock.lock()

guard let filesCache = cache["files"] as? [String: Any],
let entry = filesCache[file] as? [String: Any],
let cacheHash = entry["hash"] as? Int,
cacheHash == hash,
let violations = entry["violations"] as? [[String: Any]] else {
lock.unlock()
return nil
}

lock.unlock()
return violations.flatMap { StyleViolation.fromCache($0, file: file) }
}

public func save(to url: URL) throws {
lock.lock()
let json = toJSON(cache)
lock.unlock()
try json.write(to: url, atomically: true, encoding: .utf8)
}

private func dictionaryForViolation(_ violation: StyleViolation) -> [String: Any] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift 3 API Design Guidelines: dictionary(for violation: StyleViolation)

return [
"line": violation.location.line ?? NSNull() as Any,
"character": violation.location.character ?? NSNull() as Any,
"severity": violation.severity.rawValue,
"type": violation.ruleDescription.name,
"rule_id": violation.ruleDescription.identifier,
"reason": violation.reason
]
}
}

extension StyleViolation {
fileprivate static func fromCache(_ cache: [String: Any], file: String) -> StyleViolation? {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift 3 API Design Guidelines: from(cache: [String: Any], file: String) -> StyleViolation?

guard let severity = (cache["severity"] as? String).flatMap(ViolationSeverity.init(identifier:)),
let name = cache["type"] as? String,
let ruleId = cache["rule_id"] as? String,
let reason = cache["reason"] as? String else {
return nil
}

let line = cache["line"] as? Int
let character = cache["character"] as? Int

let ruleDescription = RuleDescription(identifier: ruleId, name: name, description: reason)
let location = Location(file: file, line: line, character: character)
let violation = StyleViolation(ruleDescription: ruleDescription, severity: severity,
location: location, reason: reason)

return violation
}
}
22 changes: 22 additions & 0 deletions Source/SwiftLintFramework/Models/Version.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Version.swift
// SwiftLint
//
// Created by Marcelo Fabri on 27/12/16.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📆 🇺🇸 🔥

// Copyright © 2016 Realm. All rights reserved.
//

import Foundation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole file should just be public let version = "0.15.0". There's no point in getting the version from the bundle and then fall back on a string literal in case that fails if the values will always match and it'd be a bug for them not to...

The template idea that's rendered into this file via make is 👌 though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 for not reading from the bundle, but I think it'd be better if we kept this namespaced inside a struct so we can avoid using String everywhere. This would also make it easier to implement version pinning if we want.


public struct Version {
public let value: String

public static let current: Version = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you do public static let current = Version(value: "0.15.0")?

if let value = Bundle(identifier: "io.realm.SwiftLintFramework")?
.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
return Version(value: value)
}

return Version(value: "0.15.0")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be reverted or done in a way that make set_version updates it as well

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in caee985

}()
}
4 changes: 4 additions & 0 deletions Source/SwiftLintFramework/Models/ViolationSeverity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
public enum ViolationSeverity: String, Comparable {
case warning
case error

public init?(identifier: String) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this init when rawValue does the exact same thing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was having issues with the rawValue initializer not being visible in other modules. But as I've moved LinterCache to SwiftLintFramework, this shouldn't be an issue anymore.

self.init(rawValue: identifier)
}
}

// MARK: Comparable
Expand Down
3 changes: 1 addition & 2 deletions Source/SwiftLintFramework/Reporters/HTMLReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ private let formatter: DateFormatter = {
return formatter
}()

private let swiftlintVersion = Bundle(identifier: "io.realm.SwiftLintFramework")?
.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
private let swiftlintVersion = Version.current.value

public struct HTMLReporter: Reporter {
public static let identifier = "html"
Expand Down
20 changes: 15 additions & 5 deletions Source/swiftlint/Commands/LintCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ struct LintCommand: CommandProtocol {
var violations = [StyleViolation]()
let configuration = Configuration(options: options)
let reporter = reporterFrom(options: options, configuration: configuration)
let cache = LinterCache.makeCache(options: options, configuration: configuration)
let visitorMutationQueue = DispatchQueue(label: "io.realm.swiftlint.lintVisitorMutation")
return configuration.visitLintableFiles(options) { linter in
return configuration.visitLintableFiles(options, cache: cache) { linter in
let currentViolations: [StyleViolation]
if options.benchmark {
let start = Date()
Expand Down Expand Up @@ -58,6 +59,9 @@ struct LintCommand: CommandProtocol {
fileBenchmark.save()
ruleBenchmark.save()
}

cache?.save(options: options, configuration: configuration)

return LintCommand.successOrExit(numberOfSeriousViolations: numberOfSeriousViolations,
strictWithViolations: options.strict && !violations.isEmpty)
}
Expand Down Expand Up @@ -113,12 +117,14 @@ struct LintOptions: OptionsProtocol {
let benchmark: Bool
let reporter: String
let quiet: Bool
let cachePath: String
let ignoreCache: Bool

// swiftlint:disable line_length
static func create(_ path: String) -> (_ useSTDIN: Bool) -> (_ configurationFile: String) -> (_ strict: Bool) -> (_ useScriptInputFiles: Bool) -> (_ benchmark: Bool) -> (_ reporter: String) -> (_ quiet: Bool) -> LintOptions {
return { useSTDIN in { configurationFile in { strict in { useScriptInputFiles in { benchmark in { reporter in { quiet in
self.init(path: path, useSTDIN: useSTDIN, configurationFile: configurationFile, strict: strict, useScriptInputFiles: useScriptInputFiles, benchmark: benchmark, reporter: reporter, quiet: quiet)
}}}}}}}
static func create(_ path: String) -> (_ useSTDIN: Bool) -> (_ configurationFile: String) -> (_ strict: Bool) -> (_ useScriptInputFiles: Bool) -> (_ benchmark: Bool) -> (_ reporter: String) -> (_ quiet: Bool) -> (_ cachePath: String) -> (_ ignoreCache: Bool) -> LintOptions {
return { useSTDIN in { configurationFile in { strict in { useScriptInputFiles in { benchmark in { reporter in { quiet in { cachePath in { ignoreCache in
self.init(path: path, useSTDIN: useSTDIN, configurationFile: configurationFile, strict: strict, useScriptInputFiles: useScriptInputFiles, benchmark: benchmark, reporter: reporter, quiet: quiet, cachePath: cachePath, ignoreCache: ignoreCache)
}}}}}}}}}
}

static func evaluate(_ mode: CommandMode) -> Result<LintOptions, CommandantError<CommandantError<()>>> {
Expand All @@ -137,5 +143,9 @@ struct LintOptions: OptionsProtocol {
<*> mode <| Option(key: "reporter", defaultValue: "",
usage: "the reporter used to log errors and warnings")
<*> mode <| quietOption(action: "linting")
<*> mode <| Option(key: "cache-path", defaultValue: "",
usage: "the cache that should be used when linting")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the location of the cache used when linting"

<*> mode <| Option(key: "no-cache", defaultValue: false,
usage: "ignore cache when linting")
}
}
4 changes: 2 additions & 2 deletions Source/swiftlint/Commands/VersionCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
import Commandant
import Foundation
import Result
import SwiftLintFramework

private let version = Bundle(identifier: "io.realm.SwiftLintFramework")!
.object(forInfoDictionaryKey: "CFBundleShortVersionString")!
private let version = Version.current.value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can just use Version.current.value directly in VersionCommand.run(_:), so you can remove this constant.


struct VersionCommand: CommandProtocol {
let verb = "version"
Expand Down
Loading