diff --git a/.gitignore b/.gitignore index 95c4320..0e11e71 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /Packages /*.xcodeproj xcuserdata/ +/AnyLintTempTests diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..6409aac --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,121 @@ +# Basic Configuration +opt_in_rules: +- anyobject_protocol +- array_init +- attributes +- closure_end_indentation +- closure_spacing +- collection_alignment +- conditional_returns_on_newline +- contains_over_filter_count +- contains_over_filter_is_empty +- contains_over_first_not_nil +- contains_over_range_nil_comparison +- convenience_type +- empty_collection_literal +- empty_count +- empty_string +- empty_xctest_method +- explicit_init +- explicit_type_interface +- fallthrough +- fatal_error_message +- file_name +- file_name_no_space +- file_types_order +- first_where +- flatmap_over_map_reduce +- function_default_parameter_at_end +- identical_operands +- implicit_return +- implicitly_unwrapped_optional +- indentation_width +- joined_default_parameter +- last_where +- legacy_multiple +- legacy_random +- literal_expression_end_indentation +- lower_acl_than_parent +- missing_docs +- modifier_order +- multiline_arguments +- multiline_arguments_brackets +- multiline_literal_brackets +- multiline_parameters +- multiline_parameters_brackets +- nslocalizedstring_key +- number_separator +- object_literal +- operator_usage_whitespace +- optional_enum_case_matching +- override_in_extension +- pattern_matching_keywords +- prefer_self_type_over_type_of_self +- private_action +- private_outlet +- prohibited_super_call +- reduce_into +- redundant_nil_coalescing +- redundant_type_annotation +- single_test_class +- sorted_first_last +- sorted_imports +- static_operator +- strong_iboutlet +- switch_case_on_newline +- toggle_bool +- trailing_closure +- type_contents_order +- unavailable_function +- unneeded_parentheses_in_closure_argument +- untyped_error_in_catch +- unused_declaration +- unused_import +- vertical_parameter_alignment_on_call +- vertical_whitespace_between_cases +- vertical_whitespace_closing_braces +- vertical_whitespace_opening_braces +- xct_specific_matcher +- yoda_condition + +included: + - Sources + - Tests + +excluded: + - Tests/LinuxMain.swift + +disabled_rules: + - todo + +# Rule Configurations +conditional_returns_on_newline: + if_only: true + +explicit_type_interface: + allow_redundancy: true + excluded: + - local + +file_name: + suffix_pattern: "Ext" + +identifier_name: + max_length: 60 + excluded: + - id + - db + - to + +line_length: + warning: 160 + ignores_comments: true + +nesting: + type_level: 3 + +trailing_comma: + mandatory_comma: true + +trailing_whitespace: + ignores_comments: false diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata index 706eede..919434a 100644 --- a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82d0bfe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). + +
+Formatting Rules for Entries +Each entry should use the following format: + +```markdown +- Summary of what was changed in a single line using past tense & followed by two whitespaces. + Issue: [#0](https://github.com/Flinesoft/AnyLint/issues/0) | PR: [#0](https://github.com/Flinesoft/AnyLint/pull/0) | Author: [Cihat Gündüz](https://github.com/Jeehut) +``` + +Note that at the end of the summary line, you need to add two whitespaces (` `) for correct rendering on GitHub. + +If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries separated by `, `. Also, remove entries not needed in the second line. +
+ +## [Unreleased] +### Added +- None. +### Changed +- None. +### Deprecated +- None. +### Removed +- None. +### Fixed +- None. +### Security +- None. + +## [0.1.0] - 2020-03-22 +Initial public release. diff --git a/Formula/anylint.rb b/Formula/anylint.rb new file mode 100644 index 0000000..d6f8682 --- /dev/null +++ b/Formula/anylint.rb @@ -0,0 +1,16 @@ +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 => "?" + head "https://github.com/Flinesoft/AnyLint.git" + + depends_on :xcode => ["11.3", :build] + + def install + system "make", "install", "prefix=#{prefix}" + end + + test do + system bin/"anylint", "-v" + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1efe33 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Flinesoft (alias Cihat Gündüz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c3b254 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +SHELL = /bin/bash + +prefix ?= /usr/local +bindir ?= $(prefix)/bin +libdir ?= $(prefix)/lib +srcdir = Sources + +REPODIR = $(shell pwd) +BUILDDIR = $(REPODIR)/.build +SOURCES = $(wildcard $(srcdir)/**/*.swift) + +.DEFAULT_GOAL = all + +.PHONY: all +all: anylint + +anylint: $(SOURCES) + @swift build \ + -c release \ + --disable-sandbox \ + --build-path "$(BUILDDIR)" + +.PHONY: install +install: anylint + @install -d "$(bindir)" "$(libdir)" + @install "$(BUILDDIR)/release/anylint" "$(bindir)" + +.PHONY: uninstall +uninstall: + @rm -rf "$(bindir)/anylint" + +.PHONY: clean +distclean: + @rm -f $(BUILDDIR)/release + +.PHONY: clean +clean: distclean + @rm -rf $(BUILDDIR) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..aa38a47 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "Rainbow", + "repositoryURL": "https://github.com/onevcat/Rainbow.git", + "state": { + "branch": null, + "revision": "9c52c1952e9b2305d4507cf473392ac2d7c9b155", + "version": "3.1.5" + } + }, + { + "package": "SwiftCLI", + "repositoryURL": "https://github.com/jakeheis/SwiftCLI.git", + "state": { + "branch": null, + "revision": "c72c4564f8c0a24700a59824880536aca45a4cae", + "version": "6.0.1" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index f3df8ca..c0ad908 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,40 @@ // swift-tools-version:5.1 -// The swift-tools-version declares the minimum version of Swift required to build this package. - import PackageDescription let package = Package( name: "AnyLint", products: [ - // Products define the executables and libraries produced by a package, and make them visible to other packages. - .library( - name: "AnyLint", - targets: ["AnyLint"]), + .library(name: "AnyLint", targets: ["AnyLint"]), + .executable(name: "anylint", targets: ["AnyLintCLI"]), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.5"), + .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.1"), ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "AnyLint", - dependencies: []), + dependencies: ["Utility"] + ), .testTarget( name: "AnyLintTests", - dependencies: ["AnyLint"]), + dependencies: ["AnyLint"] + ), + .target( + name: "AnyLintCLI", + dependencies: ["Rainbow", "SwiftCLI", "Utility"] + ), + .testTarget( + name: "AnyLintCLITests", + dependencies: ["AnyLintCLI"] + ), + .target( + name: "Utility", + dependencies: ["Rainbow"] + ), + .testTarget( + name: "UtilityTests", + dependencies: ["Utility"] + ) ] ) diff --git a/README.md b/README.md index 71f570d..2242a9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,321 @@ +

+ + Build Status + + + Code Quality + + + Coverage + + + Version: 0.1.0 + + + License: MIT + +
+ + PayPal: Donate + + + GitHub: Become a sponsor + + + Patreon: Become a patron + +

+ +

+ Installation + • Getting Started + • Configuration + • Donation + • Issues + • Contributing + • License +

+ # AnyLint -A description of this package. +Lint anything by combining the power of Swift & regular expressions. + +## Installation + +### Via [Homebrew](https://brew.sh): + +To **install** AnyLint the first time, run these commands: + +```bash +brew tap Flinesoft/AnyLint https://github.com/Flinesoft/AnyLint.git +brew install anylint +``` + +To **update** it to the latest version, run this instead: + +```bash +brew upgrade anylint +``` + +### Via [Mint](https://github.com/yonaskolb/Mint): + +To **install** AnyLint or **update** to the latest version, run this command: + +```bash +mint install Flinesoft/AnyLint +``` + +## Getting Started + +To initialize AnyLint in a project, run: + +```bash +anylint --init blank +``` + +This will create the Swift script file `lint.swift` with the following contents: + +```swift +#!/usr/local/bin/swift-sh +import AnyLint // @Flinesoft ~> 0.1.0 + +// MARK: - Variables +// some example variables + +// MARK: - Checks +// some example lint checks + +// MARK: - Log Summary & Exit +Lint.logSummaryAndExit() +``` + +The most important thing to note is that the **first two lines and the last line are required** for AnyLint to work properly. + +Other than that, all the other code in between can be adjusted and that's actually where you configure your lint checks (a few examples are provided by default in the `blank` template). Note that the first two lines declare the file to be a Swift script using [swift-sh](https://github.com/mxcl/swift-sh). Thus, you can run any Swift code and even import Swift packages (see the [swift-sh docs](https://github.com/mxcl/swift-sh#usage)) if you need to. The last line makes sure that all violations found in the process of running the previous code are reported properly and exits the script with the proper exit code. + +Having this configuration file, you can now run `anylint` to run your lint checks. By default, if any check fails, the entire command fails and reports the violation reason. To learn more about how to configure your own checks, see the [Configuration](#configuration) section below. + +If you want to create and run multiple configuration files or if you want a different name or location for the default config file, you can pass the `--path` option, which can be used multiple times as well like this: + +Initializes the configuration files at the given locations: +```bash +anylint --init blank --path Sources/lint.swift --path Tests/lint.swift +``` + +Runs the lint checks for both configuration files: +```bash +anylint --path Sources/lint.swift --path Tests/lint.swift +``` + +## Configuration + +AnyLint provides three different kinds of lint checks: + +1. `checkFileContents`: Matches the contents of a text file to a given regex. +2. `checkFilePaths`: Matches the file paths of the current directory to a given regex. +3. `customCheck`: Allows to write custom Swift code to do other kinds of checks. + +Several examples of lint checks can be found in the [`lint.swift` file of this very project](https://github.com/Flinesoft/AnyLint/blob/main/lint.swift). + +### Basic Types + +Independent from the method used, there are a few types specified in the AnyLint package you should know of. + +#### Regex + +Many parameters in the above mentioned lint check methods are of `Regex` type. A `Regex` can be initialized in several ways: + +1. Using a **String**: + ```swift + let regex = Regex(#"(foo|bar)[0-9]+"#) // => /(foo|bar)[0-9]+/` + ``` +2. Using a **String Literal**: + ```swift + let regex: Regex = #"(foo|bar)[0-9]+"# // => /(foo|bar)[0-9]+/ + ``` +3. Using a **Dictionary Literal**: (use for [named capture groups](https://www.regular-expressions.info/named.html)) + ```swift + let regex: Regex = ["key": #"foo|bar"#, "num": "[0-9]+"] + // => /(?foo|bar)(?[0-9]+)/ + ``` + +Note that we recommend using [raw strings](https://www.hackingwithswift.com/articles/162/how-to-use-raw-strings-in-swift) (`#"foo"#` instead of `"foo"`) for all regexes to get rid of double escaping backslashes (e.g. `\\s` becomes `\s`). This also allows for testing regexes in online regex editors like [rubular](https://rubular.com/) first and then copy & pasting from them without any additional escaping. + +#### CheckInfo + +A `CheckInfo` contains the basic information about a lint check. It consists of: + +1. `id`: The identifier of your lint check. For example: `EmptyTodo` +2. `hint`: The hint explaining the cause of the violation or the steps to fix it. +3. `severity`: The severity of violations. One of `error`, `warning`, `info`. Default: `error` + +While there is an initializer available, we recommend using a String Literal instead like so: + +```swift +// accepted structure: (@): +let checkInfo: CheckInfo = "ReadmePath: The README file should be named exactly `README.md`." +``` + +### Check File Contents + +AnyLint has rich support for checking the contents of a file using a regex. The design follows the approach "make simple things simple and hard things possible". Thus, let's explain the `checkFileContents` method with a simple and a complex example. + +In its simplest form, the method just requires a `checkInfo` and a `regex`: + +```swift +// MARK: empty_todo +try Lint.checkFileContents( + checkInfo: "EmptyTodo: TODO comments should not be empty.", + regex: #"// TODO: *\n"# +) +``` + +But we *strongly recommend* to always provide also: + +1. `matchingExamples`: Array of strings expected to match the given string for `regex` validation. +2. `nonMatchingExamples`: Array of strings not matching the given string for `regex` validation. +3. `includeFilters`: Array of `Regex` objects to include to the file paths to check. + +The first two will be used on each run of AnyLint to check if the provided `regex` actually works as expected. If any of the `matchingExamples` doesn't match or if any of the `nonMatchingExamples` _does_ match, the entire AnyLint command will fail early. This a built-in validation step to help preventing a lot of issues and increasing your confidence on the lint checks. + +The third one is recommended because it increases the performance of the linter. Only files at paths matching at least one of the provided regexes will be checked. If not provided, all files within the current directory will be read recursively for each check, which is inefficient. + +Here's the *recommended minimum example*: + +```swift +// MARK: - Variables +let swiftSourceFiles: Regex = #"Sources/.*\.swift"# +let swiftTestFiles: Regex = #"Tests/.*\.swift"# + +// MARK: - Checks +// MARK: empty_todo +try Lint.checkFileContents( + checkInfo: "EmptyTodo: TODO comments should not be empty.", + regex: #"// TODO: *\n"#, + matchingExamples: ["// TODO:\n"], + nonMatchingExamples: ["// TODO: not yet implemented\n"], + includeFilters: [swiftSourceFiles, swiftTestFiles] +) +``` + +There's 3 more parameters you can optionally set if needed: + +1. `excludeFilters`: Array of `Regex` objects to exclude from the file paths to check. +2. `autoCorrectReplacement`: Replacement string which can reference any capture groups in the `regex`. +3. `autoCorrectExamples`: Example structs with `before` and `after` for autocorrection validation. + +The `excludeFilters` can be used alternatively to the `includeFilters` or alongside them. If used alongside, exclusion will take precedence over inclusion. + +If `autoCorrectReplacement` is provided, AnyLint will automatically replace matches of `regex` with the given replacement string. Capture groups are supported, both in numbered style (`([a-z]+)(\d+)` => `$1$2`) and named group style (`(?[a-z])(?\d+)` => `$alpha$num`). When provided, we strongly recommend to also provide `autoCorrectExamples` for validation. Like for `matchingExamples` / `nonMatchingExamples` the entire command will fail early if one of the examples doesn't correct from the `before` string to the expected `after` string. + +> *Caution:* When using the `autoCorrectReplacement` parameter, be sure to double-check that your regex doesn't match too much content. Additionally, we strongly recommend to commit your changes regularly to have some backup. + +Here's a *full example using all parameters* at once: + +```swift +// MARK: - Variables +let swiftSourceFiles: Regex = #"Sources/.*\.swift"# +let swiftTestFiles: Regex = #"Tests/.*\.swift"# + +// MARK: - Checks +// MARK: empty_method_body +try Lint.checkFileContents( + checkInfo: "EmptyMethodBody: Don't use whitespaces for the body of empty methods.", + regex: [ + "declaration": #"func [^\(\s]+\([^{]*\)"#, + "spacing": #"\s*"#, + "body": #"\{\s+\}"# + ], + matchingExamples: [ + "func foo2bar() { }", + "func foo2bar(x: Int, y: Int) { }", + "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", + ], + nonMatchingExamples: [ + "func foo2bar() {}", + "func foo2bar(x: Int, y: Int) {}" + ], + includeFilters: [swiftSourceFiles], + excludeFilters: [swiftTestFiles], + autoCorrectReplacement: "$declaration {}", + autoCorrectExamples: [ + AutoCorrection(before: "func foo2bar() { }", after: "func foo2bar() {}"), + AutoCorrection(before: "func foo2bar(x: Int, y: Int) { }", after: "func foo2bar(x: Int, y: Int) {}"), + AutoCorrection(before: "func foo2bar()\n{\n \n}", after: "func foo2bar() {}"), + ] +) +``` + +### Check File Paths + +The `checkFilePaths` method has all the same parameters like the `checkFileContents` method, so please read the above section to learn more about them. There's only one difference and one additional parameter: + +1. `autoCorrectReplacement`: Here, this will safely move the file using the path replacement. +2. `violateIfNoMatchesFound`: Will report a violation if _no_ matches are found if `true`. Default: `false` + +As this method is about file paths and not file contents, the `autoCorrectReplacement` actually also fixes the paths, which corresponds to moving files from the `before` state to the `after` state. Note that moving/renaming files here is done safely, which means that if a file already exists at the resulting path, the command will fail. + +By default, `checkFilePaths` will fail if the given `regex` matches a file. If you want to check for the _existence_ of a file though, you can set `violateIfNoMatchesFound` to `true` instead, then the method will fail if it does _not_ matchn any file. + +### Custom Checks + +AnyLint allows you to do any kind of lint checks (thus its name) as it gives you the full power of the Swift programming language and it's packages ecosystem. The `customCheck` method needs to be used to profit from this flexibility. And it's actually the simplest of the three methods, consisting of only two parameters: + +1. `checkInfo`: Provides some general information on the lint check. +2. `customClosure`: Your custom logic which produces an array of `Violation` objects. + +Note that the `Violation` type just holds some additional information on the file, matched string, location in the file and applied autocorrection and that all these fields are optional. It is a simple struct used by the AnyLint reporter for more detailed output, no logic attached. The only required field is the `CheckInfo` object which caused the violation. + +If you want to use regexes in your custom code, you can learn more about how you can match strings with a `Regex` object on [the HandySwift docs](https://github.com/Flinesoft/AnyLint/blob/main/Sources/Utility/Regex.swift) (the project, the class was taken from) or read the [code documentation comments](https://github.com/Flinesoft/AnyLint/blob/main/Sources/Utility/Regex.swift). + +When using the `customCheck`, you might want to also include some Swift packages for [easier file handling](https://github.com/JohnSundell/Files) or [running shell commands](https://github.com/JohnSundell/ShellOut). You can do so by adding them at the top of the file like so: + +> TODO: Improve the below code example with something more useful & realistic. + +```swift +#!/usr/local/bin/swift-sh +import AnyLint // @Flinesoft ~> 0.1.0 +import Files // @JohnSundell ~> 4.1.1 +import ShellOut // @JohnSundell ~> 2.3.0 + +// MARK: echo +try Lint.customCheck(checkInfo: "Echo: Always say hello to the world.") { + var violations: [Violation] = [] + + // use ShellOut package + let output = try shellOut(to: "echo", arguments: ["Hello world"]) + // ... + + // use Files package + try Folder(path: "MyFolder").files.forEach { file in + // ... + } + + return violations +} + +// MARK: - Log Summary & Exit +Lint.logSummaryAndExit() +``` + +## 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)**. + +Thank you very much for any donation, it really helps out a lot! 💯 + +## Contributing + +Contributions are welcome. Feel free to open an issue on GitHub with your ideas or implement an idea yourself and post a pull request. If you want to contribute code, please try to follow the same syntax and semantic in your **commit messages** (see rationale [here](http://chris.beams.io/posts/git-commit/)). Also, please make sure to add an entry to the `CHANGELOG.md` file which explains your change. + +## License + +This library is released under the [MIT License](http://opensource.org/licenses/MIT). See LICENSE for details. diff --git a/Sources/AnyLint/AnyLint.swift b/Sources/AnyLint/AnyLint.swift deleted file mode 100644 index 78f37fa..0000000 --- a/Sources/AnyLint/AnyLint.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct AnyLint { - var text = "Hello, World!" -} diff --git a/Sources/AnyLint/AutoCorrection.swift b/Sources/AnyLint/AutoCorrection.swift new file mode 100644 index 0000000..51eadee --- /dev/null +++ b/Sources/AnyLint/AutoCorrection.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Information about an autocorrection. +public struct AutoCorrection { + /// The matching text before applying the autocorrection. + public let before: String + + /// The matching text after applying the autocorrection. + public let after: String + + var appliedMessageLines: [String] { + [ + "Autocorrection applied (before >>> after):", + "> ✗ \(before.showNewlines())", + ">>>", + "> ✓ \(after.showNewlines())", + ] + } + + /// Initializes an autocorrection. + public init(before: String, after: String) { + self.before = before + self.after = after + } +} diff --git a/Sources/AnyLint/CheckInfo.swift b/Sources/AnyLint/CheckInfo.swift new file mode 100644 index 0000000..70e91f9 --- /dev/null +++ b/Sources/AnyLint/CheckInfo.swift @@ -0,0 +1,73 @@ +import Foundation +import Utility + +/// Provides some basic information needed in each lint check. +public struct CheckInfo { + /// The identifier of the check defined here. Can be used when defining exceptions within files for specific lint checks. + public let id: String + + /// The hint to be shown as guidance on what the issue is and how to fix it. Can reference any capture groups in the first regex parameter (e.g. `contentRegex`). + public let hint: String + + /// The severity level for the report in case the check fails. + public let severity: Severity + + /// Initializes a new info object for the lint check. + public init(id: String, hint: String, severity: Severity = .error) { + self.id = id + self.hint = hint + self.severity = severity + } +} + +extension CheckInfo: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension CheckInfo: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + let customSeverityRegex: Regex = [ + "id": #"^[^@:]+"#, + "severitySeparator": #"@"#, + "severity": #"[^:]+"#, + "hintSeparator": #": ?"#, + "hint": #".*$"#, + ] + + if let customSeverityMatch = customSeverityRegex.firstMatch(in: value) { + let id = customSeverityMatch.captures[0]! + let severityString = customSeverityMatch.captures[2]! + let hint = customSeverityMatch.captures[4]! + + guard let severity = Severity.from(string: severityString) else { + log.message("Specified severity '\(severityString)' for check '\(id)' unknown. Use one of [error, warning, info].", level: .error) + log.exit(status: .failure) + exit(EXIT_FAILURE) // only reachable in unit tests + } + + self = CheckInfo(id: id, hint: hint, severity: severity) + } else { + let defaultSeverityRegex: Regex = [ + "id": #"^[^@:]+"#, + "hintSeparator": #": ?"#, + "hint": #".*$"#, + ] + + guard let defaultSeverityMatch = defaultSeverityRegex.firstMatch(in: value) else { + log.message( + "Could not convert String literal '\(value)' to type CheckInfo. Please check the structure to be: (@): ", + level: .error + ) + log.exit(status: .failure) + exit(EXIT_FAILURE) // only reachable in unit tests + } + + let id = defaultSeverityMatch.captures[0]! + let hint = defaultSeverityMatch.captures[2]! + + self = CheckInfo(id: id, hint: hint) + } + } +} diff --git a/Sources/AnyLint/Checkers/Checker.swift b/Sources/AnyLint/Checkers/Checker.swift new file mode 100644 index 0000000..d0c0f56 --- /dev/null +++ b/Sources/AnyLint/Checkers/Checker.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol Checker { + func performCheck() throws -> [Violation] +} diff --git a/Sources/AnyLint/Checkers/FileContentsChecker.swift b/Sources/AnyLint/Checkers/FileContentsChecker.swift new file mode 100644 index 0000000..135454c --- /dev/null +++ b/Sources/AnyLint/Checkers/FileContentsChecker.swift @@ -0,0 +1,57 @@ +import Foundation +import Utility + +struct FileContentsChecker { + let checkInfo: CheckInfo + let regex: Regex + let filePathsToCheck: [String] + let autoCorrectReplacement: String? +} + +extension FileContentsChecker: Checker { + func performCheck() throws -> [Violation] { + var violations: [Violation] = [] + + for filePath in filePathsToCheck { + if let fileData = fileManager.contents(atPath: filePath), let fileContents = String(data: fileData, encoding: .utf8) { + var newFileContents: String = fileContents + + for match in regex.matches(in: fileContents).reversed() { + // TODO: [cg_2020-03-13] use capture group named 'pointer' if exists + let locationInfo = fileContents.locationInfo(of: match.range.lowerBound) + + let appliedAutoCorrection: AutoCorrection? = { + guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } + + let newMatchString = regex.replaceAllCaptures(in: match.string, with: autoCorrectReplacement) + newFileContents.replaceSubrange(match.range, with: newMatchString) + + return AutoCorrection(before: match.string, after: newMatchString) + }() + + // TODO: [cg_2020-03-13] autocorrect if autocorrection is available + violations.append( + Violation( + checkInfo: checkInfo, + filePath: filePath, + matchedString: match.string, + locationInfo: locationInfo, + appliedAutoCorrection: appliedAutoCorrection + ) + ) + } + + if newFileContents != fileContents { + try newFileContents.write(toFile: filePath, atomically: true, encoding: .utf8) + } + } else { + log.message( + "Could not read contents of file at \(filePath). Make sure it is a text file and is formatted as UTF8.", + level: .warning + ) + } + } + + return violations.reversed() + } +} diff --git a/Sources/AnyLint/Checkers/FilePathsChecker.swift b/Sources/AnyLint/Checkers/FilePathsChecker.swift new file mode 100644 index 0000000..8fcbfe3 --- /dev/null +++ b/Sources/AnyLint/Checkers/FilePathsChecker.swift @@ -0,0 +1,42 @@ +import Foundation +import Utility + +struct FilePathsChecker { + let checkInfo: CheckInfo + let regex: Regex + let filePathsToCheck: [String] + let autoCorrectReplacement: String? + let violateIfNoMatchesFound: Bool +} + +extension FilePathsChecker: Checker { + func performCheck() throws -> [Violation] { + var violations: [Violation] = [] + + if violateIfNoMatchesFound { + let matchingFilePathsCount = filePathsToCheck.filter { regex.matches($0) }.count + if matchingFilePathsCount <= 0 { + violations.append( + Violation(checkInfo: checkInfo, filePath: nil, locationInfo: nil, appliedAutoCorrection: nil) + ) + } + } else { + for filePath in filePathsToCheck where regex.matches(filePath) { + let appliedAutoCorrection: AutoCorrection? = try { + guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } + + let newFilePath = regex.replaceAllCaptures(in: filePath, with: autoCorrectReplacement) + try fileManager.moveFileSafely(from: filePath, to: newFilePath) + + return AutoCorrection(before: filePath, after: newFilePath) + }() + + violations.append( + Violation(checkInfo: checkInfo, filePath: filePath, locationInfo: nil, appliedAutoCorrection: appliedAutoCorrection) + ) + } + } + + return violations + } +} diff --git a/Sources/AnyLint/Extensions/StringExt.swift b/Sources/AnyLint/Extensions/StringExt.swift new file mode 100644 index 0000000..33eccae --- /dev/null +++ b/Sources/AnyLint/Extensions/StringExt.swift @@ -0,0 +1,23 @@ +import Foundation +import Utility + +/// `Regex` is a swifty regex engine built on top of the NSRegularExpression api. +public typealias Regex = Utility.Regex + +extension String { + /// Info about the exact location of a character in a given file. + public typealias LocationInfo = (line: Int, charInLine: Int) + + func locationInfo(of index: String.Index) -> LocationInfo { + let prefix = self[startIndex ..< index] + let prefixLines = prefix.components(separatedBy: .newlines) + guard let lastPrefixLine = prefixLines.last else { return (line: 1, charInLine: 1) } + + let charInLine = prefix.last == "\n" ? 1 : lastPrefixLine.count + 1 + return (line: prefixLines.count, charInLine: charInLine) + } + + func showNewlines() -> String { + components(separatedBy: .newlines).joined(separator: #"\n"#) + } +} diff --git a/Sources/AnyLint/Extensions/URLExt.swift b/Sources/AnyLint/Extensions/URLExt.swift new file mode 100644 index 0000000..2bdb1a6 --- /dev/null +++ b/Sources/AnyLint/Extensions/URLExt.swift @@ -0,0 +1,8 @@ +import Foundation +import Utility + +extension URL { + var relativePathFromCurrent: String { + String(path.replacingOccurrences(of: fileManager.currentDirectoryPath, with: "").dropFirst()) + } +} diff --git a/Sources/AnyLint/FilesSearch.swift b/Sources/AnyLint/FilesSearch.swift new file mode 100644 index 0000000..179dcf8 --- /dev/null +++ b/Sources/AnyLint/FilesSearch.swift @@ -0,0 +1,60 @@ +import Foundation +import Utility + +/// Helper to search for files and filter using Regexes. +public enum FilesSearch { + static func allFiles(within path: String, includeFilters: [Regex], excludeFilters: [Regex] = []) -> [String] { + guard let url = URL(string: path, relativeTo: fileManager.currentDirectoryUrl) else { + log.message("Could not convert path '\(path)' to type URL.", level: .error) + log.exit(status: .failure) + return [] // only reachable in unit tests + } + + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey], + options: [], + errorHandler: nil + ) else { + log.message("Couldn't create enumerator for path '\(path)'.", level: .error) + log.exit(status: .failure) + return [] // only reachable in unit tests + } + + var filePaths: [String] = [] + + for case let fileUrl as URL in enumerator { + guard + let resourceValues = try? fileUrl.resourceValues(forKeys: [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey]), + let isHiddenFilePath = resourceValues.isHidden, + let isRegularFilePath = resourceValues.isRegularFile + else { + log.message("Could not read resource values for file at \(fileUrl.path)", level: .error) + log.exit(status: .failure) + return [] // only reachable in unit tests + } + + // skip if any exclude filter applies + if excludeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) { + if !isRegularFilePath { + enumerator.skipDescendants() + } + continue + } + + // skip hidden files and directories + if isHiddenFilePath { + if !isRegularFilePath { + enumerator.skipDescendants() + } + continue + } + + if isRegularFilePath, includeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) { + filePaths.append(fileUrl.relativePathFromCurrent) + } + } + + return filePaths + } +} diff --git a/Sources/AnyLint/Lint.swift b/Sources/AnyLint/Lint.swift new file mode 100644 index 0000000..c6f2d27 --- /dev/null +++ b/Sources/AnyLint/Lint.swift @@ -0,0 +1,206 @@ +import Foundation +import Utility + +/// The linter type providing APIs for checking anything using regular expressions. +public enum Lint { + /// Checks the contents of files. + /// + /// - Parameters: + /// - checkInfo: The info object providing some general information on the lint check. + /// - regex: The regex to use for matching the contents of files. By defaults points to the start of the regex, unless you provide the named group 'pointer'. + /// - matchingExamples: An array of example contents where the `regex` is expected to trigger. Optionally, the expected pointer position can be marked with ↘. + /// - nonMatchingExamples: An array of example contents where the `regex` is expected not to trigger. + /// - includeFilters: An array of regexes defining which files should be incuded in the check. Will check all files matching any of the given regexes. + /// - excludeFilters: An array of regexes defining which files should be excluded from the check. Will ignore all files matching any of the given regexes. Takes precedence over includes. + /// - autoCorrectReplacement: A replacement string which can reference any capture groups in the `regex` to use for autocorrection. + /// - autoCorrectExamples: An array of example structs with a `before` and an `after` String object to check if autocorrection works properly. + public static func checkFileContents( + checkInfo: CheckInfo, + regex: Regex, + matchingExamples: [String] = [], + nonMatchingExamples: [String] = [], + includeFilters: [Regex] = [#".*"#], + excludeFilters: [Regex] = [], + autoCorrectReplacement: String? = nil, + autoCorrectExamples: [AutoCorrection] = [] + ) throws { + validate(regex: regex, matchesForEach: matchingExamples, checkInfo: checkInfo) + validate(regex: regex, doesNotMatchAny: nonMatchingExamples, checkInfo: checkInfo) + validateParameterCombinations( + checkInfo: checkInfo, + autoCorrectReplacement: autoCorrectReplacement, + autoCorrectExamples: autoCorrectExamples, + violateIfNoMatchesFound: nil + ) + + if let autoCorrectReplacement = autoCorrectReplacement { + validateAutocorrectsAll( + checkInfo: checkInfo, + examples: autoCorrectExamples, + regex: regex, + autocorrectReplacement: autoCorrectReplacement + ) + } + + let filePathsToCheck: [String] = FilesSearch.allFiles( + within: fileManager.currentDirectoryPath, + includeFilters: includeFilters, + excludeFilters: excludeFilters + ) + + let violations = try FileContentsChecker( + checkInfo: checkInfo, + regex: regex, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: autoCorrectReplacement + ).performCheck() + Statistics.shared.found(violations: violations, in: checkInfo) + } + + /// Checks the names of files. + /// + /// - Parameters: + /// - checkInfo: The info object providing some general information on the lint check. + /// - regex: The regex to use for matching the paths of files. By defaults points to the start of the regex, unless you provide the named group 'pointer'. + /// - matchingExamples: An array of example paths where the `regex` is expected to trigger. Optionally, the expected pointer position can be marked with ↘. + /// - nonMatchingExamples: An array of example paths where the `regex` is expected not to trigger. + /// - includeFilters: Defines which files should be incuded in check. Checks all files matching any of the given regexes. + /// - excludeFilters: Defines which files should be excluded from check. Ignores all files matching any of the given regexes. Takes precedence over includes. + /// - autoCorrectReplacement: A replacement string which can reference any capture groups in the `regex` to use for autocorrection. + /// - autoCorrectExamples: An array of example structs with a `before` and an `after` String object to check if autocorrection works properly. + /// - violateIfNoMatchesFound: Inverts the violation logic to report a single violation if no matches are found instead of reporting a violation for each match. + public static func checkFilePaths( + checkInfo: CheckInfo, + regex: Regex, + matchingExamples: [String] = [], + nonMatchingExamples: [String] = [], + includeFilters: [Regex] = [#".*"#], + excludeFilters: [Regex] = [], + autoCorrectReplacement: String? = nil, + autoCorrectExamples: [AutoCorrection] = [], + violateIfNoMatchesFound: Bool = false + ) throws { + validate(regex: regex, matchesForEach: matchingExamples, checkInfo: checkInfo) + validate(regex: regex, doesNotMatchAny: nonMatchingExamples, checkInfo: checkInfo) + validateParameterCombinations( + checkInfo: checkInfo, + autoCorrectReplacement: autoCorrectReplacement, + autoCorrectExamples: autoCorrectExamples, + violateIfNoMatchesFound: violateIfNoMatchesFound + ) + + if let autoCorrectReplacement = autoCorrectReplacement { + validateAutocorrectsAll( + checkInfo: checkInfo, + examples: autoCorrectExamples, + regex: regex, + autocorrectReplacement: autoCorrectReplacement + ) + } + + let filePathsToCheck: [String] = FilesSearch.allFiles( + within: fileManager.currentDirectoryPath, + includeFilters: includeFilters, + excludeFilters: excludeFilters + ) + + let violations = try FilePathsChecker( + checkInfo: checkInfo, + regex: regex, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: autoCorrectReplacement, + violateIfNoMatchesFound: violateIfNoMatchesFound + ).performCheck() + + Statistics.shared.found(violations: violations, in: checkInfo) + } + + /// Run custom logic as checks. + /// + /// - Parameters: + /// - checkInfo: The info object providing some general information on the lint check. + /// - customClosure: The custom logic to run which produces an array of `Violation` objects for any violations. + public static func customCheck(checkInfo: CheckInfo, customClosure: () -> [Violation]) { + Statistics.shared.found(violations: customClosure(), in: checkInfo) + } + + /// 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) { + Statistics.shared.logSummary() + + if Statistics.shared.violationsBySeverity[.error]!.isFilled { + log.exit(status: .failure) + } else if failOnWarnings && Statistics.shared.violationsBySeverity[.warning]!.isFilled { + log.exit(status: .failure) + } else { + log.exit(status: .success) + } + } + + static func validate(regex: Regex, matchesForEach matchingExamples: [String], checkInfo: CheckInfo) { + for example in matchingExamples { + if !regex.matches(example) { + // TODO: [cg_2020-03-14] check position of ↘ is the matching line and char. + log.message( + "Couldn't find a match for regex \(regex) in check '\(checkInfo.id)' within matching example:\n\(example)", + level: .error + ) + log.exit(status: .failure) + } + } + } + + static func validate(regex: Regex, doesNotMatchAny nonMatchingExamples: [String], checkInfo: CheckInfo) { + for example in nonMatchingExamples { + if regex.matches(example) { + // TODO: [cg_2020-03-14] check position of ↘ is the matching line and char. + log.message( + "Unexpectedly found a match for regex \(regex) in check '\(checkInfo.id)' within non-matching example:\n\(example)", + level: .error + ) + log.exit(status: .failure) + } + } + } + + static func validateAutocorrectsAll(checkInfo: CheckInfo, examples: [AutoCorrection], regex: Regex, autocorrectReplacement: String) { + for autocorrect in examples { + let autocorrected = regex.replaceAllCaptures(in: autocorrect.before, with: autocorrectReplacement) + if autocorrected != autocorrect.after { + log.message( + """ + Autocorrecting example for \(checkInfo.id) did not result in expected output. + Before: '\(autocorrect.before.showNewlines())' + After: '\(autocorrected.showNewlines())' + Expected: '\(autocorrect.after.showNewlines())' + """, + level: .error + ) + log.exit(status: .failure) + } + } + } + + static func validateParameterCombinations( + checkInfo: CheckInfo, + autoCorrectReplacement: String?, + autoCorrectExamples: [AutoCorrection], + violateIfNoMatchesFound: Bool? + ) { + if autoCorrectExamples.isFilled && autoCorrectReplacement == nil { + log.message( + "`autoCorrectExamples` provided for check \(checkInfo.id) without specifying an `autoCorrectReplacement`.", + level: .warning + ) + } + + guard autoCorrectReplacement == nil || violateIfNoMatchesFound != true else { + log.message( + "Incompatible options specified for check \(checkInfo.id): autoCorrectReplacement and violateIfNoMatchesFound can't be used together.", + level: .error + ) + log.exit(status: .failure) + return // only reachable in unit tests + } + } +} diff --git a/Sources/AnyLint/Severity.swift b/Sources/AnyLint/Severity.swift new file mode 100644 index 0000000..2ece7ea --- /dev/null +++ b/Sources/AnyLint/Severity.swift @@ -0,0 +1,43 @@ +import Foundation +import Utility + +/// Defines the severity of a lint check. +public enum Severity: Int, CaseIterable { + /// Use for checks that are mostly informational and not necessarily problematic. + case info + + /// Use for checks that might potentially be problematic. + case warning + + /// Use for checks that probably are problematic. + case error + + var logLevel: Logger.PrintLevel { + switch self { + case .info: + return .info + + case .warning: + return .warning + + case .error: + return .error + } + } + + static func from(string: String) -> Severity? { + switch string { + case "info", "i": + return .info + + case "warning", "w": + return .warning + + case "error", "e": + return .error + + default: + return nil + } + } +} diff --git a/Sources/AnyLint/Statistics.swift b/Sources/AnyLint/Statistics.swift new file mode 100644 index 0000000..3ef9dfa --- /dev/null +++ b/Sources/AnyLint/Statistics.swift @@ -0,0 +1,85 @@ +import Foundation +import Utility + +final class Statistics { + static let shared = Statistics() + + var executedChecks: [CheckInfo] = [] + var violationsPerCheck: [CheckInfo: [Violation]] = [:] + var violationsBySeverity: [Severity: [Violation]] = [.info: [], .warning: [], .error: []] + + var maxViolationSeverity: Severity? { + violationsBySeverity.keys.filter { !violationsBySeverity[$0]!.isEmpty }.max { $0.rawValue < $1.rawValue } + } + + private init() {} + + func found(violations: [Violation], in check: CheckInfo) { + executedChecks.append(check) + violationsPerCheck[check] = violations + violationsBySeverity[check.severity]!.append(contentsOf: violations) + } + + /// Use for unit testing only. + func reset() { + executedChecks = [] + violationsPerCheck = [:] + violationsBySeverity = [.info: [], .warning: [], .error: []] + } + + func logSummary() { + 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) + } + } + } 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)" + + 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) + } + } +} diff --git a/Sources/AnyLint/Violation.swift b/Sources/AnyLint/Violation.swift new file mode 100644 index 0000000..b1f9c64 --- /dev/null +++ b/Sources/AnyLint/Violation.swift @@ -0,0 +1,41 @@ +import Foundation +import Rainbow +import Utility + +/// A violation found in a check. +public struct Violation { + /// The info about the chack that caused this violation. + public let checkInfo: CheckInfo + + /// The file path the violation is related to. + public let filePath: String? + + /// The matched string that violates the check. + public let matchedString: String? + + /// The info about the exact location of the violation within the file. Will be ignored if no `filePath` specified. + public let locationInfo: String.LocationInfo? + + /// The autocorrection applied to fix this violation. + public let appliedAutoCorrection: AutoCorrection? + + init( + checkInfo: CheckInfo, + filePath: String? = nil, + matchedString: String? = nil, + locationInfo: String.LocationInfo? = nil, + appliedAutoCorrection: AutoCorrection? = nil + ) { + self.checkInfo = checkInfo + self.filePath = filePath + self.matchedString = matchedString + self.locationInfo = locationInfo + self.appliedAutoCorrection = appliedAutoCorrection + } + + func locationMessage() -> String? { + guard let filePath = filePath else { return nil } + guard let locationInfo = locationInfo else { return filePath } + return "\(filePath):\(locationInfo.line):\(locationInfo.charInLine)" + } +} diff --git a/Sources/AnyLintCLI/Commands/SingleCommand.swift b/Sources/AnyLintCLI/Commands/SingleCommand.swift new file mode 100644 index 0000000..160f2d5 --- /dev/null +++ b/Sources/AnyLintCLI/Commands/SingleCommand.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftCLI +import Utility + +class SingleCommand: Command { + // MARK: - Basics + var name: String = CLIConstants.commandName + var shortDescription: String = "Lint anything by combining the power of Swift & regular expressions." + + // MARK: - Subcommands + @Flag("-v", "--version", description: "Print the current tool version") + var version: Bool + + @Key("-i", "--init", description: "Configure AnyLint with a default template. Has to be one of: [\(CLIConstants.initTemplateCases)]") + var initTemplateName: String? + + // MARK: - Options + @VariadicKey("-p", "--path", description: "Provide a custom path to the config file (multiple usage supported)") + var customPaths: [String] + + // MARK: - Execution + func execute() throws { + // version subcommand + if version { + try VersionTask().perform() + log.exit(status: .success) + } + + let configurationPaths = customPaths.isEmpty + ? [fileManager.currentDirectoryPath.appendingPathComponent(CLIConstants.defaultConfigFileName)] + : customPaths + + // init subcommand + if let initTemplateName = initTemplateName { + guard let initTemplate = InitTask.Template(rawValue: initTemplateName) else { + log.message("Unknown default template '\(initTemplateName)' – use one of: [\(CLIConstants.initTemplateCases)]", level: .error) + log.exit(status: .failure) + return // only reachable in unit tests + } + + for configPath in configurationPaths { + try InitTask(configFilePath: configPath, template: initTemplate).perform() + } + log.exit(status: .success) + } + + // lint main command + var anyConfigFileFailed = false + for configPath in configurationPaths { + do { + try LintTask(configFilePath: configPath).perform() + } catch LintTask.LintError.configFileFailed { + anyConfigFileFailed = true + } + } + exit(anyConfigFileFailed ? EXIT_FAILURE : EXIT_SUCCESS) + } +} diff --git a/Sources/AnyLintCLI/ConfigurationTemplates/BlankTemplate.swift b/Sources/AnyLintCLI/ConfigurationTemplates/BlankTemplate.swift new file mode 100644 index 0000000..fa429da --- /dev/null +++ b/Sources/AnyLintCLI/ConfigurationTemplates/BlankTemplate.swift @@ -0,0 +1,78 @@ +import Foundation +import Utility + +// swiftlint:disable function_body_length + +enum BlankTemplate: ConfigurationTemplate { + static func fileContents() -> String { + commonPrefix + #""" + // MARK: - Variables + let readmeFile: Regex = #"README\.md"# + + // MARK: - Checks + // MARK: readme + try Lint.checkFilePaths( + checkInfo: "readme: Each project should have a README.md file, explaining how to use or contribute to the project.", + regex: #"^README\.md$"#, + matchingExamples: ["README.md"], + nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], + violateIfNoMatchesFound: true + ) + + // MARK: readme_path + try Lint.checkFilePaths( + checkInfo: "readme_path: The README file should be named exactly `README.md`.", + regex: #"^(.*/)?([Rr][Ee][Aa][Dd][Mm][Ee]\.markdown|readme\.md|Readme\.md|ReadMe\.md)$"#, + matchingExamples: ["README.markdown", "readme.md", "ReadMe.md"], + nonMatchingExamples: ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "api/help.md"], + autoCorrectReplacement: "$1README.md", + autoCorrectExamples: [ + AutoCorrection(before: "api/readme.md", after: "api/README.md"), + AutoCorrection(before: "ReadMe.md", after: "README.md"), + AutoCorrection(before: "README.markdown", after: "README.md"), + ] + ) + + // MARK: readme_top_level_title + try Lint.checkFileContents( + checkInfo: "readme_top_level_title: The README.md file should only contain a single top level title.", + regex: #"(^|\n)#[^#](.*\n)*\n#[^#]"#, + matchingExamples: [ + """ + # Title + ## Subtitle + Lorem ipsum + + # Other Title + ## Other Subtitle + """, + ], + nonMatchingExamples: [ + """ + # Title + ## Subtitle + Lorem ipsum #1 and # 2. + + ## Other Subtitle + ### Other Subsubtitle + """, + ], + includeFilters: [readmeFile] + ) + + // MARK: readme_typo_license + try Lint.checkFileContents( + checkInfo: "readme_typo_license: Misspelled word 'license'.", + regex: #"([\s#]L|l)isence([\s\.,:;])"#, + matchingExamples: [" lisence:", "## Lisence\n"], + nonMatchingExamples: [" license:", "## License\n"], + includeFilters: [readmeFile], + autoCorrectReplacement: "$1icense$2", + autoCorrectExamples: [ + AutoCorrection(before: " lisence:", after: " license:"), + AutoCorrection(before: "## Lisence\n", after: "## License\n"), + ] + ) + """# + commonSuffix + } +} diff --git a/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift b/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift new file mode 100644 index 0000000..9860e90 --- /dev/null +++ b/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift @@ -0,0 +1,16 @@ +import Foundation +import Utility + +protocol ConfigurationTemplate { + static func fileContents() -> String +} + +extension ConfigurationTemplate { + static var commonPrefix: String { + "#!\(CLIConstants.swiftShPath)\nimport AnyLint // @Flinesoft ~> \(Constants.currentVersion)\n\n" + } + + static var commonSuffix: String { + "\n\n// MARK: - Log Summary & Exit\nLint.logSummaryAndExit()\n" + } +} diff --git a/Sources/AnyLintCLI/Globals/CLIConstants.swift b/Sources/AnyLintCLI/Globals/CLIConstants.swift new file mode 100644 index 0000000..07111e3 --- /dev/null +++ b/Sources/AnyLintCLI/Globals/CLIConstants.swift @@ -0,0 +1,8 @@ +import Foundation + +enum CLIConstants { + static let commandName: String = "anylint" + static let defaultConfigFileName: String = "lint.swift" + static let initTemplateCases: String = InitTask.Template.allCases.map { $0.rawValue }.joined(separator: ", ") + static let swiftShPath: String = "/usr/local/bin/swift-sh" +} diff --git a/Sources/AnyLintCLI/Globals/ValidateOrFail.swift b/Sources/AnyLintCLI/Globals/ValidateOrFail.swift new file mode 100644 index 0000000..f83a5cb --- /dev/null +++ b/Sources/AnyLintCLI/Globals/ValidateOrFail.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftCLI +import Utility + +enum ValidateOrFail { + /// Fails if swift-sh is not installed. + static func swiftShInstalled() { + guard fileManager.fileExists(atPath: CLIConstants.swiftShPath) else { + log.message( + "swift-sh not installed – please follow instructions on https://github.com/mxcl/swift-sh#installation to install.", + level: .error + ) + log.exit(status: .failure) + return // only reachable in unit tests + } + } + + static func configFileExists(at configFilePath: String) throws { + guard fileManager.fileExists(atPath: configFilePath) else { + log.message( + "No configuration file found at \(configFilePath) – consider running `\(CLIConstants.commandName) --init` with a template.", + level: .error + ) + log.exit(status: .failure) + return // only reachable in unit tests + } + } +} diff --git a/Sources/AnyLintCLI/Tasks/InitTask.swift b/Sources/AnyLintCLI/Tasks/InitTask.swift new file mode 100644 index 0000000..dbcf06f --- /dev/null +++ b/Sources/AnyLintCLI/Tasks/InitTask.swift @@ -0,0 +1,44 @@ +import Foundation +import SwiftCLI +import Utility + +struct InitTask { + enum Template: String, CaseIterable { + case blank + + var configFileContents: String { + switch self { + case .blank: + return BlankTemplate.fileContents() + } + } + } + + let configFilePath: String + let template: Template +} + +extension InitTask: TaskHandler { + func perform() throws { + guard !fileManager.fileExists(atPath: configFilePath) else { + log.message("Configuration file already exists at path '\(configFilePath)'.", level: .error) + log.exit(status: .failure) + return // only reachable in unit tests + } + + log.message("Making sure config file directory exists ...", level: .info) + try Task.run(bash: "mkdir -p '\(configFilePath.parentDirectoryPath)'") + + log.message("Creating config file using template '\(template.rawValue)' ...", level: .info) + fileManager.createFile( + atPath: configFilePath, + contents: template.configFileContents.data(using: .utf8), + attributes: nil + ) + + log.message("Making config file executable ...", level: .info) + try Task.run(bash: "chmod +x '\(configFilePath)'") + + log.message("Successfully created config file at \(configFilePath)", level: .success) + } +} diff --git a/Sources/AnyLintCLI/Tasks/LintTask.swift b/Sources/AnyLintCLI/Tasks/LintTask.swift new file mode 100644 index 0000000..c4f6703 --- /dev/null +++ b/Sources/AnyLintCLI/Tasks/LintTask.swift @@ -0,0 +1,33 @@ +import Foundation +import SwiftCLI +import Utility + +struct LintTask { + let configFilePath: String +} + +extension LintTask: TaskHandler { + enum LintError: Error { + case configFileFailed + } + + /// - Throws: `LintError.configFileFailed` if running a configuration file fails + func perform() throws { + try ValidateOrFail.configFileExists(at: configFilePath) + + if !fileManager.isExecutableFile(atPath: configFilePath) { + try Task.run(bash: "chmod +x '\(configFilePath)'") + } + + ValidateOrFail.swiftShInstalled() + + do { + log.message("Start linting using config file at \(configFilePath) ...", level: .info) + try Task.run(bash: "\(configFilePath.absolutePath)") + 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) + throw LintError.configFileFailed + } + } +} diff --git a/Sources/AnyLintCLI/Tasks/TaskHandler.swift b/Sources/AnyLintCLI/Tasks/TaskHandler.swift new file mode 100644 index 0000000..9986c03 --- /dev/null +++ b/Sources/AnyLintCLI/Tasks/TaskHandler.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol TaskHandler { + func perform() throws +} diff --git a/Sources/AnyLintCLI/Tasks/VersionTask.swift b/Sources/AnyLintCLI/Tasks/VersionTask.swift new file mode 100644 index 0000000..e043f26 --- /dev/null +++ b/Sources/AnyLintCLI/Tasks/VersionTask.swift @@ -0,0 +1,10 @@ +import Foundation +import Utility + +struct VersionTask { /* for extension purposes only */ } + +extension VersionTask: TaskHandler { + func perform() throws { + log.message(Constants.currentVersion, level: .info) + } +} diff --git a/Sources/AnyLintCLI/main.swift b/Sources/AnyLintCLI/main.swift new file mode 100644 index 0000000..d4cc92b --- /dev/null +++ b/Sources/AnyLintCLI/main.swift @@ -0,0 +1,5 @@ +import Foundation +import SwiftCLI + +let singleCommand = CLI(singleCommand: SingleCommand()) +singleCommand.goAndExit() diff --git a/Sources/Utility/Constants.swift b/Sources/Utility/Constants.swift new file mode 100644 index 0000000..d8b5041 --- /dev/null +++ b/Sources/Utility/Constants.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Shortcut to access the default `FileManager` within this project. +public let fileManager = FileManager.default + +/// Shortcut to access the `Logger` within this project. +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.0" + + /// The name of this tool. + public static let toolName: String = "AnyLint" +} diff --git a/Sources/Utility/Extensions/CollectionExt.swift b/Sources/Utility/Extensions/CollectionExt.swift new file mode 100644 index 0000000..7d9a099 --- /dev/null +++ b/Sources/Utility/Extensions/CollectionExt.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Collection { + /// A Boolean value indicating whether the collection is not empty. + public var isFilled: Bool { + !isEmpty + } +} diff --git a/Sources/Utility/Extensions/FileManagerExt.swift b/Sources/Utility/Extensions/FileManagerExt.swift new file mode 100644 index 0000000..188d608 --- /dev/null +++ b/Sources/Utility/Extensions/FileManagerExt.swift @@ -0,0 +1,48 @@ +import Foundation + +extension FileManager { + /// The current directory `URL`. + public var currentDirectoryUrl: URL { + URL(string: currentDirectoryPath)! + } + + /// Moves a file from one path to another, making sure that all directories are created and no files are overwritten. + public func moveFileSafely(from sourcePath: String, to targetPath: String) throws { + guard fileExists(atPath: sourcePath) else { + log.message("No file found at \(sourcePath) to move.", level: .error) + log.exit(status: .failure) + return // only reachable in unit tests + } + + guard !fileExists(atPath: targetPath) || sourcePath.lowercased() == targetPath.lowercased() else { + log.message("File already exists at target path \(targetPath) – can't move from \(sourcePath).", level: .warning) + return + } + + let targetParentDirectoryPath = targetPath.parentDirectoryPath + if !fileExists(atPath: targetParentDirectoryPath) { + try createDirectory(atPath: targetParentDirectoryPath, withIntermediateDirectories: true, attributes: nil) + } + + guard fileExistsAndIsDirectory(atPath: targetParentDirectoryPath) else { + log.message("Expected \(targetParentDirectoryPath) to be a directory.", level: .error) + log.exit(status: .failure) + return // only reachable in unit tests + } + + if sourcePath.lowercased() == targetPath.lowercased() { + // workaround issues on case insensitive file systems + let temporaryTargetPath = targetPath + UUID().uuidString + try moveItem(atPath: sourcePath, toPath: temporaryTargetPath) + try moveItem(atPath: temporaryTargetPath, toPath: targetPath) + } else { + try moveItem(atPath: sourcePath, toPath: targetPath) + } + } + + /// Checks if a file exists and the given paths and is a directory. + public func fileExistsAndIsDirectory(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + return fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/Sources/Utility/Extensions/RegexExt.swift b/Sources/Utility/Extensions/RegexExt.swift new file mode 100644 index 0000000..987b017 --- /dev/null +++ b/Sources/Utility/Extensions/RegexExt.swift @@ -0,0 +1,41 @@ +import Foundation + +extension Regex: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + do { + self = try Regex(value) + } catch { + log.message("Failed to convert String literal '\(value)' to type Regex.", level: .error) + log.exit(status: .failure) + exit(EXIT_FAILURE) // only reachable in unit tests + } + } +} + +extension Regex: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, String)...) { + do { + self = try Regex(elements.reduce(into: "") { result, element in result.append("(?<\(element.0)>\(element.1))") }) + } catch { + log.message("Failed to convert Dictionary literal '\(elements)' to type Regex.", level: .error) + log.exit(status: .failure) + exit(EXIT_FAILURE) // only reachable in unit tests + } + } +} + +extension Regex { + /// Replaces all captures groups with the given capture references. References can be numbers like `$1` and capture names like `$prefix`. + public func replaceAllCaptures(in input: String, with template: String) -> String { + replacingMatches(in: input, with: numerizedNamedCaptureRefs(in: template)) + } + + /// Numerizes references to named capture groups to work around missing named capture group replacement in `NSRegularExpression` APIs. + func numerizedNamedCaptureRefs(in replacementString: String) -> String { + let captureGroupNameRegex = Regex(#"\(\?\<([a-zA-Z0-9_-]+)\>[^\)]+\)"#) + let captureGroupNames: [String] = captureGroupNameRegex.matches(in: pattern).map { $0.captures[0]! } + return captureGroupNames.enumerated().reduce(replacementString) { result, enumeratedGroupName in + result.replacingOccurrences(of: "$\(enumeratedGroupName.element)", with: "$\(enumeratedGroupName.offset + 1)") + } + } +} diff --git a/Sources/Utility/Extensions/StringExt.swift b/Sources/Utility/Extensions/StringExt.swift new file mode 100644 index 0000000..8be934a --- /dev/null +++ b/Sources/Utility/Extensions/StringExt.swift @@ -0,0 +1,36 @@ +import Foundation + +extension String { + /// 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 + } + + return url.absoluteString + } + + /// Returns the parent directory path. + public var parentDirectoryPath: String { + guard let url = URL(string: self) else { + log.message("Could not convert path '\(self)' to type URL.", level: .error) + log.exit(status: .failure) + return "" // only reachable in unit tests + } + + return url.deletingLastPathComponent().absoluteString + } + + /// Returns the path with a components appended at it. + public func appendingPathComponent(_ pathComponent: String) -> String { + guard let pathUrl = URL(string: self) else { + log.message("Could not convert path '\(self)' to type URL.", level: .error) + log.exit(status: .failure) + return "" // only reachable in unit tests + } + + return pathUrl.appendingPathComponent(pathComponent).absoluteString + } +} diff --git a/Sources/Utility/Logger.swift b/Sources/Utility/Logger.swift new file mode 100644 index 0000000..87cb3fc --- /dev/null +++ b/Sources/Utility/Logger.swift @@ -0,0 +1,119 @@ +import Foundation +import Rainbow + +/// Helper to log output to console or elsewhere. +public final class Logger { + /// The print level type. + public enum PrintLevel: String { + /// Print success information. + case success + + /// Print any kind of information potentially interesting to users. + case info + + /// Print information that might potentially be problematic. + case warning + + /// Print information that probably is problematic. + case error + + var color: Color { + switch self { + case .success: + return Color.lightGreen + + case .info: + return Color.lightBlue + + case .warning: + return Color.yellow + + case .error: + return Color.red + } + } + } + + /// The output type. + public enum OutputType { + /// Output is targeted to a console to be read by developers. + case console + + /// Output is targeted for unit tests. Collect into globally accessible TestHelper. + case test + } + + /// The exit status. + public enum ExitStatus { + /// Successfully finished task. + case success + + /// Failed to finish task. + case failure + + var statusCode: Int32 { + switch self { + case .success: + return EXIT_SUCCESS + + case .failure: + return EXIT_FAILURE + } + } + } + + let outputType: OutputType + + init(outputType: OutputType) { + self.outputType = outputType + } + + /// Communicates a message to the chosen output target with proper formatting based on level & source. + /// + /// - 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. + public func message(_ message: String, level: PrintLevel) { + switch outputType { + case .console: + consoleMessage(message, level: level) + + case .test: + TestHelper.shared.consoleOutputs.append((message, level)) + } + } + + /// Exits the current program with the given status. + public func exit(status: ExitStatus) { + switch outputType { + case .console: + Darwin.exit(status.statusCode) + + case .test: + TestHelper.shared.exitStatus = status + } + } + + private func consoleMessage(_ message: String, level: PrintLevel) { + switch level { + case .success: + print(formattedCurrentTime(), "✅ ", message.green) + + case .info: + print(formattedCurrentTime(), "ℹ️ ", message.lightBlue) + + case .warning: + print(formattedCurrentTime(), "⚠️ ", message.yellow) + + case .error: + print(formattedCurrentTime(), "❌", message.red) + } + } + + private func formattedCurrentTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss.SSS" + let dateTime = dateFormatter.string(from: Date()) + return "\(dateTime):" + } +} diff --git a/Sources/Utility/Regex.swift b/Sources/Utility/Regex.swift new file mode 100644 index 0000000..6a55bc2 --- /dev/null +++ b/Sources/Utility/Regex.swift @@ -0,0 +1,263 @@ +// Originally from: https://github.com/sharplet/Regex & https://github.com/Flinesoft/HandySwift (modified). + +import Foundation + +/// `Regex` is a swifty regex engine built on top of the NSRegularExpression api. +public struct Regex { + // MARK: - Properties + private let regularExpression: NSRegularExpression + + /// The regex patterns string. + public let pattern: String + + // MARK: - Initializers + /// Create a `Regex` based on a pattern string. + /// + /// If `pattern` is not a valid regular expression, an error is thrown + /// describing the failure. + /// + /// - parameters: + /// - pattern: A pattern string describing the regex. + /// - options: Configure regular expression matching options. + /// For details, see `Regex.Options`. + /// + /// - throws: A value of `ErrorType` describing the invalid regular expression. + public init(_ pattern: String, options: Options = []) throws { + self.pattern = pattern + regularExpression = try NSRegularExpression( + pattern: pattern, + options: options.toNSRegularExpressionOptions + ) + } + + // MARK: - Methods: Matching + /// Returns `true` if the regex matches `string`, otherwise returns `false`. + /// + /// - parameter string: The string to test. + /// + /// - returns: `true` if the regular expression matches, otherwise `false`. + public func matches(_ string: String) -> Bool { + firstMatch(in: string) != nil + } + + /// If the regex matches `string`, returns a `Match` describing the + /// first matched string and any captures. If there are no matches, returns + /// `nil`. + /// + /// - parameter string: The string to match against. + /// + /// - returns: An optional `Match` describing the first match, or `nil`. + public func firstMatch(in string: String) -> Match? { + let firstMatch = regularExpression + .firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) + .map { Match(result: $0, in: string) } + return firstMatch + } + + /// If the regex matches `string`, returns an array of `Match`, describing + /// every match inside `string`. If there are no matches, returns an empty + /// array. + /// + /// - parameter string: The string to match against. + /// + /// - returns: An array of `Match` describing every match in `string`. + public func matches(in string: String) -> [Match] { + let matches = regularExpression + .matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) + .map { Match(result: $0, in: string) } + return matches + } + + // MARK: Replacing + /// Returns a new string where each substring matched by `regex` is replaced + /// with `template`. + /// + /// The template string may be a literal string, or include template variables: + /// the variable `$0` will be replaced with the entire matched substring, `$1` + /// with the first capture group, etc. + /// + /// For example, to include the literal string "$1" in the replacement string, + /// you must escape the "$": `\$1`. + /// + /// - parameters: + /// - regex: A regular expression to match against `self`. + /// - template: A template string used to replace matches. + /// - count: The maximum count of matches to replace, beginning with the first match. + /// + /// - returns: A string with all matches of `regex` replaced by `template`. + public func replacingMatches(in input: String, with template: String, count: Int? = nil) -> String { + var output = input + let matches = self.matches(in: input) + let rangedMatches = Array(matches[0 ..< min(matches.count, count ?? .max)]) + for match in rangedMatches.reversed() { + let replacement = match.string(applyingTemplate: template) + output.replaceSubrange(match.range, with: replacement) + } + + return output + } +} + +// MARK: - CustomStringConvertible +extension Regex: CustomStringConvertible { + /// Returns a string describing the regex using its pattern string. + public var description: String { + "/\(regularExpression.pattern)/" + } +} + +// MARK: - Equatable +extension Regex: Equatable { + /// Determines the equality of to `Regex`` instances. + /// Two `Regex` are considered equal, if both the pattern string and the options + /// passed on initialization are equal. + public static func == (lhs: Regex, rhs: Regex) -> Bool { + lhs.regularExpression.pattern == rhs.regularExpression.pattern && + lhs.regularExpression.options == rhs.regularExpression.options + } +} + +// MARK: - Hashable +extension Regex: Hashable { + /// Manages hashing of the `Regex` instance. + public func hash(into hasher: inout Hasher) { + hasher.combine(regularExpression) + } +} + +// MARK: - Options +extension Regex { + /// `Options` defines alternate behaviours of regular expressions when matching. + public struct Options: OptionSet { + // MARK: - Properties + /// Ignores the case of letters when matching. + public static let ignoreCase = Options(rawValue: 1) + + /// Ignore any metacharacters in the pattern, treating every character as + /// a literal. + public static let ignoreMetacharacters = Options(rawValue: 1 << 1) + + /// By default, "^" matches the beginning of the string and "$" matches the + /// end of the string, ignoring any newlines. With this option, "^" will + /// the beginning of each line, and "$" will match the end of each line. + public static let anchorsMatchLines = Options(rawValue: 1 << 2) + + /// Usually, "." matches all characters except newlines (\n). Using this, + /// options will allow "." to match newLines + public static let dotMatchesLineSeparators = Options(rawValue: 1 << 3) + + /// The raw value of the `OptionSet` + public let rawValue: Int + + /// Transform an instance of `Regex.Options` into the equivalent `NSRegularExpression.Options`. + /// + /// - returns: The equivalent `NSRegularExpression.Options`. + var toNSRegularExpressionOptions: NSRegularExpression.Options { + var options = NSRegularExpression.Options() + if contains(.ignoreCase) { options.insert(.caseInsensitive) } + if contains(.ignoreMetacharacters) { options.insert(.ignoreMetacharacters) } + if contains(.anchorsMatchLines) { options.insert(.anchorsMatchLines) } + if contains(.dotMatchesLineSeparators) { options.insert(.dotMatchesLineSeparators) } + return options + } + + // MARK: - Initializers + /// The raw value init for the `OptionSet` + public init(rawValue: Int) { + self.rawValue = rawValue + } + } +} + +// MARK: - Match +extension Regex { + /// A `Match` encapsulates the result of a single match in a string, + /// providing access to the matched string, as well as any capture groups within + /// that string. + public class Match: CustomStringConvertible { + // MARK: Properties + /// The entire matched string. + public lazy var string: String = { + String(describing: self.baseString[self.range]) + }() + + /// The range of the matched string. + public lazy var range: Range = { + Range(self.result.range, in: self.baseString)! + }() + + /// The matching string for each capture group in the regular expression + /// (if any). + /// + /// **Note:** Usually if the match was successful, the captures will by + /// definition be non-nil. However if a given capture group is optional, the + /// captured string may also be nil, depending on the particular string that + /// is being matched against. + /// + /// Example: + /// + /// let regex = Regex("(a)?(b)") + /// + /// regex.matches(in: "ab")first?.captures // [Optional("a"), Optional("b")] + /// regex.matches(in: "b").first?.captures // [nil, Optional("b")] + public lazy var captures: [String?] = { + let captureRanges = stride(from: 0, to: result.numberOfRanges, by: 1) + .map(result.range) + .dropFirst() + .map { [unowned self] in + Range($0, in: self.baseString) + } + + return captureRanges.map { [unowned self] captureRange in + guard let captureRange = captureRange else { return nil } + return String(describing: self.baseString[captureRange]) + } + }() + + let result: NSTextCheckingResult + + let baseString: String + + // MARK: - Initializers + internal init(result: NSTextCheckingResult, in string: String) { + precondition( + result.regularExpression != nil, + "NSTextCheckingResult must originate from regular expression parsing." + ) + + self.result = result + self.baseString = string + } + + // MARK: - Methods + /// Returns a new string where the matched string is replaced according to the `template`. + /// + /// The template string may be a literal string, or include template variables: + /// the variable `$0` will be replaced with the entire matched substring, `$1` + /// with the first capture group, etc. + /// + /// For example, to include the literal string "$1" in the replacement string, + /// you must escape the "$": `\$1`. + /// + /// - parameters: + /// - template: The template string used to replace matches. + /// + /// - returns: A string with `template` applied to the matched string. + public func string(applyingTemplate template: String) -> String { + let replacement = result.regularExpression!.replacementString( + for: result, + in: baseString, + offset: 0, + template: template + ) + + return replacement + } + + // MARK: - CustomStringConvertible + /// Returns a string describing the match. + public var description: String { + "Match<\"\(string)\">" + } + } +} diff --git a/Sources/Utility/TestHelper.swift b/Sources/Utility/TestHelper.swift new file mode 100644 index 0000000..1b72080 --- /dev/null +++ b/Sources/Utility/TestHelper.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A helper class for Unit Testing only. +public final class TestHelper { + /// The console output data. + public typealias ConsoleOutput = (message: String, level: Logger.PrintLevel) + + /// The shared `TestHelper` object. + public static let shared = TestHelper() + + /// Use only in Unit Tests. + public var consoleOutputs: [ConsoleOutput] = [] + + /// Use only in Unit Tests. + public var exitStatus: Logger.ExitStatus? + + /// Deletes all data collected until now. + public func reset() { + consoleOutputs = [] + exitStatus = nil + } +} diff --git a/Tests/AnyLintCLITests/AnyLintCLITests.swift b/Tests/AnyLintCLITests/AnyLintCLITests.swift new file mode 100644 index 0000000..5be114c --- /dev/null +++ b/Tests/AnyLintCLITests/AnyLintCLITests.swift @@ -0,0 +1,7 @@ +import XCTest + +final class AnyLintCLITests: XCTestCase { + func testExample() { + // TODO: [cg_2020-03-07] not yet implemented + } +} diff --git a/Tests/AnyLintTests/AnyLintTests.swift b/Tests/AnyLintTests/AnyLintTests.swift deleted file mode 100644 index 5b13bbb..0000000 --- a/Tests/AnyLintTests/AnyLintTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import AnyLint - -final class AnyLintTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(AnyLint().text, "Hello, World!") - } - - static var allTests = [ - ("testExample", testExample), - ] -} diff --git a/Tests/AnyLintTests/CheckInfoTests.swift b/Tests/AnyLintTests/CheckInfoTests.swift new file mode 100644 index 0000000..ad69b2e --- /dev/null +++ b/Tests/AnyLintTests/CheckInfoTests.swift @@ -0,0 +1,34 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class CheckInfoTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testInitWithStringLiteral() { + XCTAssert(TestHelper.shared.consoleOutputs.isEmpty) + + let checkInfo1: CheckInfo = "test1@error: hint1" + XCTAssertEqual(checkInfo1.id, "test1") + XCTAssertEqual(checkInfo1.hint, "hint1") + XCTAssertEqual(checkInfo1.severity, .error) + + let checkInfo2: CheckInfo = "test2@warning: hint2" + XCTAssertEqual(checkInfo2.id, "test2") + XCTAssertEqual(checkInfo2.hint, "hint2") + XCTAssertEqual(checkInfo2.severity, .warning) + + let checkInfo3: CheckInfo = "test3@info: hint3" + XCTAssertEqual(checkInfo3.id, "test3") + XCTAssertEqual(checkInfo3.hint, "hint3") + XCTAssertEqual(checkInfo3.severity, .info) + + let checkInfo4: CheckInfo = "test4: hint4" + XCTAssertEqual(checkInfo4.id, "test4") + XCTAssertEqual(checkInfo4.hint, "hint4") + XCTAssertEqual(checkInfo4.severity, .error) + } +} diff --git a/Tests/AnyLintTests/Checkers/FileContentsCheckerTests.swift b/Tests/AnyLintTests/Checkers/FileContentsCheckerTests.swift new file mode 100644 index 0000000..5b33c5c --- /dev/null +++ b/Tests/AnyLintTests/Checkers/FileContentsCheckerTests.swift @@ -0,0 +1,39 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class FileContentsCheckerTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testPerformCheck() { + let temporaryFiles: [TemporaryFile] = [ + (subpath: "Sources/Hello.swift", contents: "let x = 5\nvar y = 10"), + (subpath: "Sources/World.swift", contents: "let x=5\nvar y=10"), + ] + + withTemporaryFiles(temporaryFiles) { filePathsToCheck in + let checkInfo = CheckInfo(id: "whitespacing", hint: "Always add a single whitespace around '='.", severity: .warning) + let violations = try FileContentsChecker( + checkInfo: checkInfo, + regex: #"(let|var) \w+=\w+"#, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil + ).performCheck() + + XCTAssertEqual(violations.count, 2) + + XCTAssertEqual(violations[0].checkInfo, checkInfo) + XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[0].locationInfo!.line, 1) + XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) + + XCTAssertEqual(violations[1].checkInfo, checkInfo) + XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[1].locationInfo!.line, 2) + XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) + } + } +} diff --git a/Tests/AnyLintTests/Checkers/FilePathsCheckerTests.swift b/Tests/AnyLintTests/Checkers/FilePathsCheckerTests.swift new file mode 100644 index 0000000..0d59ca7 --- /dev/null +++ b/Tests/AnyLintTests/Checkers/FilePathsCheckerTests.swift @@ -0,0 +1,77 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class FilePathsCheckerTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testPerformCheck() { + withTemporaryFiles( + [ + (subpath: "Sources/Hello.swift", contents: ""), + (subpath: "Sources/World.swift", contents: ""), + ] + ) { filePathsToCheck in + let violations = try sayHelloChecker(filePathsToCheck: filePathsToCheck).performCheck() + XCTAssertEqual(violations.count, 0) + } + + withTemporaryFiles([(subpath: "Sources/World.swift", contents: "")]) { filePathsToCheck in + let violations = try sayHelloChecker(filePathsToCheck: filePathsToCheck).performCheck() + + XCTAssertEqual(violations.count, 1) + + XCTAssertEqual(violations[0].checkInfo, sayHelloCheck()) + XCTAssertNil(violations[0].filePath) + XCTAssertNil(violations[0].locationInfo) + XCTAssertNil(violations[0].locationInfo) + } + + withTemporaryFiles( + [ + (subpath: "Sources/Hello.swift", contents: ""), + (subpath: "Sources/World.swift", contents: ""), + ] + ) { filePathsToCheck in + let violations = try noWorldChecker(filePathsToCheck: filePathsToCheck).performCheck() + + XCTAssertEqual(violations.count, 1) + + XCTAssertEqual(violations[0].checkInfo, noWorldCheck()) + XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/World.swift") + XCTAssertNil(violations[0].locationInfo) + XCTAssertNil(violations[0].locationInfo) + } + } + + private func sayHelloChecker(filePathsToCheck: [String]) -> FilePathsChecker { + FilePathsChecker( + checkInfo: sayHelloCheck(), + regex: #".*Hello\.swift"#, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + violateIfNoMatchesFound: true + ) + } + + private func sayHelloCheck() -> CheckInfo { + CheckInfo(id: "say_hello", hint: "Should always say hello.", severity: .info) + } + + private func noWorldChecker(filePathsToCheck: [String]) -> FilePathsChecker { + FilePathsChecker( + checkInfo: noWorldCheck(), + regex: #".*World\.swift"#, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + violateIfNoMatchesFound: false + ) + } + + private func noWorldCheck() -> CheckInfo { + CheckInfo(id: "no_world", hint: "Do not include the global world, be more specific instead.", severity: .error) + } +} diff --git a/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift b/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift new file mode 100644 index 0000000..a59d9d8 --- /dev/null +++ b/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift @@ -0,0 +1,25 @@ +@testable import AnyLint +import Foundation +import XCTest + +extension XCTestCase { + typealias TemporaryFile = (subpath: String, contents: String) + + var tempDir: String { "AnyLintTempTests" } + + func withTemporaryFiles(_ temporaryFiles: [TemporaryFile], testCode: ([String]) throws -> Void) { + var filePathsToCheck: [String] = [] + + for tempFile in temporaryFiles { + let tempFileUrl = FileManager.default.currentDirectoryUrl.appendingPathComponent(tempDir).appendingPathComponent(tempFile.subpath) + let tempFileParentDirUrl = tempFileUrl.deletingLastPathComponent() + try? FileManager.default.createDirectory(atPath: tempFileParentDirUrl.path, withIntermediateDirectories: true, attributes: nil) + FileManager.default.createFile(atPath: tempFileUrl.path, contents: tempFile.contents.data(using: .utf8), attributes: nil) + filePathsToCheck.append(tempFileUrl.relativePathFromCurrent) + } + + try? testCode(filePathsToCheck) + + try? FileManager.default.removeItem(atPath: tempDir) + } +} diff --git a/Tests/AnyLintTests/FilesSearchTests.swift b/Tests/AnyLintTests/FilesSearchTests.swift new file mode 100644 index 0000000..115f919 --- /dev/null +++ b/Tests/AnyLintTests/FilesSearchTests.swift @@ -0,0 +1,35 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class FilesSearchTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testAllFilesWithinPath() { + withTemporaryFiles( + [ + (subpath: "Sources/Hello.swift", contents: ""), + (subpath: "Sources/World.swift", contents: ""), + (subpath: "Sources/.hidden_file", contents: ""), + (subpath: "Sources/.hidden_dir/unhidden_file", contents: ""), + ] + ) { _ in + let includeFilterFilePaths = FilesSearch.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: [try Regex("\(tempDir)/.*")], + excludeFilters: [] + ) + XCTAssertEqual(includeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift", "\(tempDir)/Sources/World.swift"]) + + let excludeFilterFilePaths = FilesSearch.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: [try Regex("\(tempDir)/.*")], + excludeFilters: ["World"] + ) + XCTAssertEqual(excludeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift"]) + } + } +} diff --git a/Tests/AnyLintTests/LintTests.swift b/Tests/AnyLintTests/LintTests.swift new file mode 100644 index 0000000..99402c6 --- /dev/null +++ b/Tests/AnyLintTests/LintTests.swift @@ -0,0 +1,118 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class LintTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testValidateRegexMatchesForEach() { + XCTAssertNil(TestHelper.shared.exitStatus) + + let regex: Regex = #"foo[0-9]?bar"# + let checkInfo = CheckInfo(id: "foo_bar", hint: "do bar", severity: .warning) + + Lint.validate( + regex: regex, + matchesForEach: ["foo1bar", "foobar", "myfoo4barbeque"], + checkInfo: checkInfo + ) + XCTAssertNil(TestHelper.shared.exitStatus) + + Lint.validate( + regex: regex, + matchesForEach: ["foo1bar", "FooBar", "myfoo4barbeque"], + checkInfo: checkInfo + ) + XCTAssertEqual(TestHelper.shared.exitStatus, .failure) + } + + func testValidateRegexDoesNotMatchAny() { + XCTAssertNil(TestHelper.shared.exitStatus) + + let regex: Regex = #"foo[0-9]?bar"# + let checkInfo = CheckInfo(id: "foo_bar", hint: "do bar", severity: .warning) + + Lint.validate( + regex: regex, + doesNotMatchAny: ["fooLbar", "FooBar", "myfoo40barbeque"], + checkInfo: checkInfo + ) + XCTAssertNil(TestHelper.shared.exitStatus) + + Lint.validate( + regex: regex, + doesNotMatchAny: ["fooLbar", "foobar", "myfoo40barbeque"], + checkInfo: checkInfo + ) + XCTAssertEqual(TestHelper.shared.exitStatus, .failure) + } + + func testValidateAutocorrectsAllExamplesWithAnonymousGroups() { + XCTAssertNil(TestHelper.shared.exitStatus) + + let anonymousCaptureRegex = try? Regex(#"([^\.]+)(\.)([^\.]+)(\.)([^\.]+)"#) + + Lint.validateAutocorrectsAll( + checkInfo: CheckInfo(id: "id", hint: "hint"), + examples: [ + AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), + AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), + ], + regex: anonymousCaptureRegex!, + autocorrectReplacement: "$5$2$3$4$1" + ) + + XCTAssertNil(TestHelper.shared.exitStatus) + + Lint.validateAutocorrectsAll( + checkInfo: CheckInfo(id: "id", hint: "hint"), + examples: [ + AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), + AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), + ], + regex: anonymousCaptureRegex!, + autocorrectReplacement: "$4$1$2$3$0" + ) + + XCTAssertEqual(TestHelper.shared.exitStatus, .failure) + } + + func testValidateAutocorrectsAllExamplesWithNamedGroups() { + XCTAssertNil(TestHelper.shared.exitStatus) + + let namedCaptureRegex: Regex = [ + "prefix": #"[^\.]+"#, + "separator1": #"\."#, + "content": #"[^\.]+"#, + "separator2": #"\."#, + "suffix": #"[^\.]+"#, + ] + + Lint.validateAutocorrectsAll( + checkInfo: CheckInfo(id: "id", hint: "hint"), + examples: [ + AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), + AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), + ], + regex: namedCaptureRegex, + autocorrectReplacement: "$suffix$separator1$content$separator2$prefix" + ) + + XCTAssertNil(TestHelper.shared.exitStatus) + + Lint.validateAutocorrectsAll( + checkInfo: CheckInfo(id: "id", hint: "hint"), + examples: [ + AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), + AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), + ], + regex: namedCaptureRegex, + autocorrectReplacement: "$sfx$sep1$cnt$sep2$pref" + ) + + XCTAssertEqual(TestHelper.shared.exitStatus, .failure) + } +} diff --git a/Tests/AnyLintTests/RegexExtTests.swift b/Tests/AnyLintTests/RegexExtTests.swift new file mode 100644 index 0000000..b729aa7 --- /dev/null +++ b/Tests/AnyLintTests/RegexExtTests.swift @@ -0,0 +1,18 @@ +@testable import AnyLint +@testable import Utility +import XCTest + +final class RegexExtTests: XCTestCase { + func testInitWithStringLiteral() { + let regex: Regex = #"(?capture[_\-\.]group)\s+\n(.*)"# + XCTAssertEqual(regex.pattern, #"(?capture[_\-\.]group)\s+\n(.*)"#) + } + + func testInitWithDictionaryLiteral() { + let regex: Regex = [ + "name": #"capture[_\-\.]group"#, + "suffix": #"\s+\n.*"#, + ] + XCTAssertEqual(regex.pattern, #"(?capture[_\-\.]group)(?\s+\n.*)"#) + } +} diff --git a/Tests/AnyLintTests/StatisticsTests.swift b/Tests/AnyLintTests/StatisticsTests.swift new file mode 100644 index 0000000..b85df9d --- /dev/null +++ b/Tests/AnyLintTests/StatisticsTests.swift @@ -0,0 +1,118 @@ +@testable import AnyLint +import Rainbow +@testable import Utility +import XCTest + +final class StatisticsTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + Statistics.shared.reset() + } + + func testFoundViolationsInCheck() { + XCTAssert(Statistics.shared.executedChecks.isEmpty) + XCTAssert(Statistics.shared.violationsBySeverity[.info]!.isEmpty) + XCTAssert(Statistics.shared.violationsBySeverity[.warning]!.isEmpty) + XCTAssert(Statistics.shared.violationsBySeverity[.error]!.isEmpty) + XCTAssert(Statistics.shared.violationsPerCheck.isEmpty) + + let checkInfo1 = CheckInfo(id: "id1", hint: "hint1", severity: .info) + Statistics.shared.found( + violations: [Violation(checkInfo: checkInfo1)], + in: checkInfo1 + ) + + XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1]) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 0) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 0) + XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 1) + + let checkInfo2 = CheckInfo(id: "id2", hint: "hint2", severity: .warning) + Statistics.shared.found( + violations: [Violation(checkInfo: checkInfo2), Violation(checkInfo: checkInfo2)], + in: CheckInfo(id: "id2", hint: "hint2", severity: .warning) + ) + + XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1, checkInfo2]) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 2) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 0) + XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 2) + + let checkInfo3 = CheckInfo(id: "id3", hint: "hint3", severity: .error) + Statistics.shared.found( + violations: [Violation(checkInfo: checkInfo3), Violation(checkInfo: checkInfo3), Violation(checkInfo: checkInfo3)], + in: CheckInfo(id: "id3", hint: "hint3", severity: .error) + ) + + XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1, checkInfo2, checkInfo3]) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 2) + XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 3) + XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 3) + } + + func testLogSummary() { // swiftlint:disable:this function_body_length + Statistics.shared.logSummary() + XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 1) + XCTAssertEqual(TestHelper.shared.consoleOutputs[0].level, .warning) + XCTAssertEqual(TestHelper.shared.consoleOutputs[0].message, "No checks found to perform.") + + TestHelper.shared.reset() + + let checkInfo1 = CheckInfo(id: "id1", hint: "hint1", severity: .info) + Statistics.shared.found( + violations: [Violation(checkInfo: checkInfo1)], + in: checkInfo1 + ) + + let checkInfo2 = CheckInfo(id: "id2", hint: "hint2", severity: .warning) + Statistics.shared.found( + violations: [ + Violation(checkInfo: checkInfo2, filePath: "Hogwarts/Harry.swift"), + Violation(checkInfo: checkInfo2, filePath: "Hogwarts/Albus.swift"), + ], + in: CheckInfo(id: "id2", hint: "hint2", severity: .warning) + ) + + let checkInfo3 = CheckInfo(id: "id3", hint: "hint3", severity: .error) + Statistics.shared.found( + violations: [ + Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Harry.swift", locationInfo: (line: 10, charInLine: 30)), + Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Harry.swift", locationInfo: (line: 72, charInLine: 17)), + Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Albus.swift", locationInfo: (line: 40, charInLine: 4)), + ], + in: CheckInfo(id: "id3", hint: "hint3", severity: .error) + ) + + Statistics.shared.logSummary() + + XCTAssertEqual( + TestHelper.shared.consoleOutputs.map { $0.level }, + [.info, .info, .warning, .warning, .warning, .warning, .error, .error, .error, .error, .error, .error] + ) + + let expectedOutputs = [ + "\("[id1]".bold) Found 1 violation(s).", + ">> Hint: hint1".bold.italic, + "\("[id2]".bold) Found 2 violation(s) at:", + "> 1. Hogwarts/Harry.swift", + "> 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", + ">> Hint: hint3".bold.italic, + "Performed 3 check(s) and found 3 error(s) & 2 warning(s).", + ] + + XCTAssertEqual(TestHelper.shared.consoleOutputs.count, expectedOutputs.count) + + for (index, expectedOutput) in expectedOutputs.enumerated() { + XCTAssertEqual(TestHelper.shared.consoleOutputs[index].message, expectedOutput) + } + } +} diff --git a/Tests/AnyLintTests/ViolationTests.swift b/Tests/AnyLintTests/ViolationTests.swift new file mode 100644 index 0000000..96e4a7d --- /dev/null +++ b/Tests/AnyLintTests/ViolationTests.swift @@ -0,0 +1,28 @@ +@testable import AnyLint +import Rainbow +@testable import Utility +import XCTest + +final class ViolationTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + Statistics.shared.reset() + } + + func testLocationMessage() { + let checkInfo = CheckInfo(id: "demo_check", hint: "Make sure to always check the demo.", severity: .warning) + XCTAssertNil(Violation(checkInfo: checkInfo).locationMessage()) + + let fileViolation = Violation(checkInfo: checkInfo, filePath: "Temp/Souces/Hello.swift") + XCTAssertEqual(fileViolation.locationMessage(), "Temp/Souces/Hello.swift") + + let locationInfoViolation = Violation( + checkInfo: checkInfo, + filePath: "Temp/Souces/World.swift", + locationInfo: String.LocationInfo(line: 5, charInLine: 15) + ) + + XCTAssertEqual(locationInfoViolation.locationMessage(), "Temp/Souces/World.swift:5:15") + } +} diff --git a/Tests/AnyLintTests/XCTestManifests.swift b/Tests/AnyLintTests/XCTestManifests.swift deleted file mode 100644 index 8ebc876..0000000 --- a/Tests/AnyLintTests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(AnyLintTests.allTests), - ] -} -#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index 880dd06..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import AnyLintTests - -var tests = [XCTestCaseEntry]() -tests += AnyLintTests.allTests() -XCTMain(tests) diff --git a/Tests/UtilityTests/Extensions/RegexExtTests.swift b/Tests/UtilityTests/Extensions/RegexExtTests.swift new file mode 100644 index 0000000..1345e5d --- /dev/null +++ b/Tests/UtilityTests/Extensions/RegexExtTests.swift @@ -0,0 +1,9 @@ +@testable import Utility +import XCTest + +final class RegexExtTests: XCTestCase { + func testStringLiteralInit() { + let regex: Regex = #".*"# + XCTAssertEqual(regex.description, #"/.*/"#) + } +} diff --git a/Tests/UtilityTests/LoggerTests.swift b/Tests/UtilityTests/LoggerTests.swift new file mode 100644 index 0000000..3496482 --- /dev/null +++ b/Tests/UtilityTests/LoggerTests.swift @@ -0,0 +1,31 @@ +@testable import Utility +import XCTest + +final class LoggerTests: XCTestCase { + override func setUp() { + log = Logger(outputType: .test) + TestHelper.shared.reset() + } + + func testMessage() { + XCTAssert(TestHelper.shared.consoleOutputs.isEmpty) + + log.message("Test", level: .info) + + XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 1) + XCTAssertEqual(TestHelper.shared.consoleOutputs[0].level, .info) + XCTAssertEqual(TestHelper.shared.consoleOutputs[0].message, "Test") + + log.message("Test 2", level: .warning) + + XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 2) + XCTAssertEqual(TestHelper.shared.consoleOutputs[1].level, .warning) + XCTAssertEqual(TestHelper.shared.consoleOutputs[1].message, "Test 2") + + log.message("Test 3", level: .error) + + XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 3) + XCTAssertEqual(TestHelper.shared.consoleOutputs[2].level, .error) + XCTAssertEqual(TestHelper.shared.consoleOutputs[2].message, "Test 3") + } +} diff --git a/lint.swift b/lint.swift new file mode 100755 index 0000000..000c1ad --- /dev/null +++ b/lint.swift @@ -0,0 +1,471 @@ +#!/usr/local/bin/swift-sh +import AnyLint // . + +// MARK: - Variables +let swiftSourceFiles: Regex = #"Sources/.*\.swift"# +let swiftTestFiles: Regex = #"Tests/.*\.swift"# +let readmeFile: Regex = #"README\.md"# + +// MARK: - Checks +// MARK: EmptyMethodBody +try Lint.checkFileContents( + checkInfo: "EmptyMethodBody: Don't use whitespace or newlines for the body of empty methods.", + regex: ["declaration": #"(init|func [^\(\s]+)\([^{]*\)"#, "spacing": #"\s*"#, "body": #"\{\s+\}"#], + matchingExamples: [ + "init() { }", + "init() {\n\n}", + "init(\n x: Int,\n y: Int\n) { }", + "func foo2bar() { }", + "func foo2bar(x: Int, y: Int) { }", + "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", + ], + nonMatchingExamples: ["init() { /* comment */ }", "init() {}", "func foo2bar() {}", "func foo2bar(x: Int, y: Int) {}"], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: "$declaration {}", + autoCorrectExamples: [ + AutoCorrection(before: "init() { }", after: "init() {}"), + AutoCorrection(before: "init(x: Int, y: Int) { }", after: "init(x: Int, y: Int) {}"), + AutoCorrection(before: "init()\n{\n \n}", after: "init() {}"), + AutoCorrection(before: "init(\n x: Int,\n y: Int\n) {\n \n}", after: "init(\n x: Int,\n y: Int\n) {}"), + AutoCorrection(before: "func foo2bar() { }", after: "func foo2bar() {}"), + AutoCorrection(before: "func foo2bar(x: Int, y: Int) { }", after: "func foo2bar(x: Int, y: Int) {}"), + AutoCorrection(before: "func foo2bar()\n{\n \n}", after: "func foo2bar() {}"), + AutoCorrection(before: "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", after: "func foo2bar(\n x: Int,\n y: Int\n) {}"), + ] +) + +// MARK: EmptyTodo +try Lint.checkFileContents( + checkInfo: "EmptyTodo: `// TODO:` comments should not be empty.", + regex: #"// TODO: ?(\[[\d\-_a-z]+\])? *\n"#, + matchingExamples: ["// TODO:\n", "// TODO: [2020-03-19]\n", "// TODO: [cg_2020-03-19] \n"], + nonMatchingExamples: ["// TODO: refactor", "// TODO: not yet implemented", "// TODO: [cg_2020-03-19] not yet implemented"], + includeFilters: [swiftSourceFiles, swiftTestFiles] +) + +// MARK: EmptyType +try Lint.checkFileContents( + checkInfo: "EmptyType: Don't keep empty types in code without commenting inside why they are needed.", + regex: #"(class|protocol|struct|enum) [^\{]+\{\s*\}"#, + matchingExamples: ["class Foo {}", "enum Constants {\n \n}", "struct MyViewModel(x: Int, y: Int, closure: () -> Void) {}"], + nonMatchingExamples: ["class Foo { /* TODO: not yet implemented */ }", "func foo() {}", "init() {}", "enum Bar { case x, y }"], + includeFilters: [swiftSourceFiles, swiftTestFiles] +) + +// MARK: GuardMultiline2 +try Lint.checkFileContents( + checkInfo: "GuardMultiline2: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + regex: [ + "newline": #"\n"#, + "guardIndent": #" *"#, + "guard": #"guard *"#, + "line1": #"[^\n]+,"#, + "line1Indent": #"\n *"#, + "line2": #"[^\n]*\S"#, + "else": #"\s*else\s*\{\s*"# + ], + matchingExamples: [ + """ + + guard let x1 = y1?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """ + ], + nonMatchingExamples: [ + """ + + guard + let x1 = y1?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """, + """ + + guard let url = URL(string: self, relativeTo: fileManager.currentDirectoryUrl) else { + return 2 + } + """, + ], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: """ + + $guardIndentguard + $guardIndent $line1 + $guardIndent $line2 + $guardIndentelse { + $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} + """, + autoCorrectExamples: [ + AutoCorrection( + before: """ + let x = 15 + guard let x1 = y1?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """, + after: """ + let x = 15 + guard + let x1 = y1?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """ + ), + ] +) + +// MARK: GuardMultiline3 +try Lint.checkFileContents( + checkInfo: "GuardMultiline3: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + regex: [ + "newline": #"\n"#, + "guardIndent": #" *"#, + "guard": #"guard *"#, + "line1": #"[^\n]+,"#, + "line1Indent": #"\n *"#, + "line2": #"[^\n]+,"#, + "line2Indent": #"\n *"#, + "line3": #"[^\n]*\S"#, + "else": #"\s*else\s*\{\s*"# + ], + matchingExamples: [ + """ + + guard let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """ + ], + nonMatchingExamples: [ + """ + + guard + let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """, + """ + + guard let url = URL(x: 1, y: 2, relativeTo: fileManager.currentDirectoryUrl) else { + return 2 + } + """, + ], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: """ + + $guardIndentguard + $guardIndent $line1 + $guardIndent $line2 + $guardIndent $line3 + $guardIndentelse { + $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} + """, + autoCorrectExamples: [ + AutoCorrection( + before: """ + let x = 15 + guard let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """, + after: """ + let x = 15 + guard + let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """ + ), + ] +) + +// MARK: GuardMultiline4 +try Lint.checkFileContents( + checkInfo: "GuardMultiline4: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + regex: [ + "newline": #"\n"#, + "guardIndent": #" *"#, + "guard": #"guard *"#, + "line1": #"[^\n]+,"#, + "line1Indent": #"\n *"#, + "line2": #"[^\n]+,"#, + "line2Indent": #"\n *"#, + "line3": #"[^\n]+,"#, + "line3Indent": #"\n *"#, + "line4": #"[^\n]*\S"#, + "else": #"\s*else\s*\{\s*"# + ], + matchingExamples: [ + """ + + guard let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """ + ], + nonMatchingExamples: [ + """ + + guard + let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """, + """ + + guard let url = URL(x: 1, y: 2, z: 3, relativeTo: fileManager.currentDirectoryUrl) else { + return 2 + } + """, + ], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: """ + + $guardIndentguard + $guardIndent $line1 + $guardIndent $line2 + $guardIndent $line3 + $guardIndent $line4 + $guardIndentelse { + $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} + """, + autoCorrectExamples: [ + AutoCorrection( + before: """ + let x = 15 + guard let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """, + after: """ + let x = 15 + guard + let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """ + ), + ] +) + +// MARK: GuardMultilineN +try Lint.checkFileContents( + checkInfo: "GuardMultilineN: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + regex: #"\n *guard *([^\n]+,\n){4,}[^\n]*\S\s*else\s*\{\s*"#, + matchingExamples: [ + """ + + guard let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let x4 = y4?.imagePath, + let x5 = y5?.imagePath, + let z = EnumType(rawValue: 15) else { + return 2 + } + """ + ], + nonMatchingExamples: [ + """ + + guard + let x1 = y1?.imagePath, + let x2 = y2?.imagePath, + let x3 = y3?.imagePath, + let x4 = y4?.imagePath, + let x5 = y5?.imagePath, + let z = EnumType(rawValue: 15) + else { + return 2 + } + """, + """ + + guard let url = URL(x1: 1, x2: 2, x3: 3, x4: 4, x5: 5, relativeTo: fileManager.currentDirectoryUrl) else { + return 2 + } + """, + ], + includeFilters: [swiftSourceFiles, swiftTestFiles] +) + +// MARK: IfAsGuard +try Lint.checkFileContents( + checkInfo: "IfAsGuard: Don't use an if statement to just return – use guard for such cases instead.", + regex: #" +if [^\{]+\{\s*return\s*[^\}]*\}(?! *else)"#, + matchingExamples: [" if x == 5 { return }", " if x == 5 {\n return nil\n}", " if x == 5 { return 500 }", " if x == 5 { return do(x: 500, y: 200) }"], + nonMatchingExamples: [" if x == 5 {\n let y = 200\n return y\n}", " if x == 5 { someMethod(x: 500, y: 200) }", " if x == 500 { return } else {"], + includeFilters: [swiftSourceFiles, swiftTestFiles] +) + +// MARK: LateForceUnwrapping3 +try Lint.checkFileContents( + checkInfo: "LateForceUnwrapping3: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + regex: [ + "openingBrace": #"\("#, + "callPart1": #"[^\s\?\.]+"#, + "separator1": #"\?\."#, + "callPart2": #"[^\s\?\.]+"#, + "separator2": #"\?\."#, + "callPart3": #"[^\s\?\.]+"#, + "separator3": #"\?\."#, + "callPart4": #"[^\s\?\.]+"#, + "closingBraceUnwrap": #"\)!"#, + ], + matchingExamples: ["let x = (viewModel?.user?.profile?.imagePath)!\n"], + nonMatchingExamples: ["call(x: (viewModel?.username)!)", "let x = viewModel!.user!.profile!.imagePath\n"], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: "$callPart1!.$callPart2!.$callPart3!.$callPart4", + autoCorrectExamples: [ + AutoCorrection(before: "let x = (viewModel?.user?.profile?.imagePath)!\n", after: "let x = viewModel!.user!.profile!.imagePath\n"), + ] +) + +// MARK: LateForceUnwrapping2 +try Lint.checkFileContents( + checkInfo: "LateForceUnwrapping2: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + regex: [ + "openingBrace": #"\("#, + "callPart1": #"[^\s\?\.]+"#, + "separator1": #"\?\."#, + "callPart2": #"[^\s\?\.]+"#, + "separator2": #"\?\."#, + "callPart3": #"[^\s\?\.]+"#, + "closingBraceUnwrap": #"\)!"#, + ], + matchingExamples: ["call(x: (viewModel?.profile?.username)!)"], + nonMatchingExamples: ["let x = (viewModel?.user?.profile?.imagePath)!\n", "let x = viewModel!.profile!.imagePath\n"], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: "$callPart1!.$callPart2!.$callPart3", + autoCorrectExamples: [ + AutoCorrection(before: "let x = (viewModel?.profile?.imagePath)!\n", after: "let x = viewModel!.profile!.imagePath\n"), + ] +) + +// MARK: LateForceUnwrapping1 +try Lint.checkFileContents( + checkInfo: "LateForceUnwrapping1: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + regex: [ + "openingBrace": #"\("#, + "callPart1": #"[^\s\?\.]+"#, + "separator1": #"\?\."#, + "callPart2": #"[^\s\?\.]+"#, + "closingBraceUnwrap": #"\)!"#, + ], + matchingExamples: ["call(x: (viewModel?.username)!)"], + nonMatchingExamples: ["call(x: (viewModel?.profile?.username)!)", "call(x: viewModel!.username)"], + includeFilters: [swiftSourceFiles, swiftTestFiles], + autoCorrectReplacement: "$callPart1!.$callPart2", + autoCorrectExamples: [ + AutoCorrection(before: "call(x: (viewModel?.username)!)", after: "call(x: viewModel!.username)"), + ] +) + +// MARK: Logger +try Lint.checkFileContents( + checkInfo: "Logger: Don't use `print` – use `log.message` instead.", + regex: #"print\([^\n]+\)"#, + matchingExamples: [#"print("Hellow World!")"#, #"print(5)"#, #"print(\n "hi"\n)"#], + nonMatchingExamples: [#"log.message("Hello world!")"#], + includeFilters: [swiftSourceFiles, swiftTestFiles], + excludeFilters: [#"Sources/.*/Logger\.swift"#] +) + +// MARK: Readme +try Lint.checkFilePaths( + checkInfo: "Readme: Each project should have a README.md file, explaining how to use or contribute to the project.", + regex: #"^README\.md$"#, + matchingExamples: ["README.md"], + nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], + violateIfNoMatchesFound: true +) + +// MARK: ReadmePath +try Lint.checkFilePaths( + checkInfo: "ReadmePath: The README file should be named exactly `README.md`.", + regex: #"^(.*/)?([Rr][Ee][Aa][Dd][Mm][Ee]\.markdown|readme\.md|Readme\.md|ReadMe\.md)$"#, + matchingExamples: ["README.markdown", "readme.md", "ReadMe.md"], + nonMatchingExamples: ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "api/help.md"], + autoCorrectReplacement: "$1README.md", + autoCorrectExamples: [ + AutoCorrection(before: "api/readme.md", after: "api/README.md"), + AutoCorrection(before: "ReadMe.md", after: "README.md"), + AutoCorrection(before: "README.markdown", after: "README.md"), + ] +) + +// MARK: ReadmeTopLevelTitle +try Lint.checkFileContents( + checkInfo: "ReadmeTopLevelTitle: The README.md file should only contain a single top level title.", + regex: #"(^|\n)#[^#](.*\n)*\n#[^#]"#, + matchingExamples: [ + """ + # Title + ## Subtitle + Lorem ipsum + + # Other Title + ## Other Subtitle + """, + ], + nonMatchingExamples: [ + """ + # Title + ## Subtitle + Lorem ipsum #1 and # 2. + + ## Other Subtitle + ### Other Subsubtitle + """, + ], + includeFilters: [readmeFile] +) + +// MARK: ReadmeTypoLicense +try Lint.checkFileContents( + checkInfo: "ReadmeTypoLicense: Misspelled word 'license'.", + regex: #"([\s#]L|l)isence([\s\.,:;])"#, + matchingExamples: [" lisence:", "## Lisence\n"], + nonMatchingExamples: [" license:", "## License\n"], + includeFilters: [readmeFile], + autoCorrectReplacement: "$1icense$2", + autoCorrectExamples: [ + AutoCorrection(before: " lisence:", after: " license:"), + AutoCorrection(before: "## Lisence\n", after: "## License\n"), + ] +) + +// MARK: - Log Summary & Exit +Lint.logSummaryAndExit()