diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3f545b6..0b22d6e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,88 +1,10 @@ -name: CI +name: Main on: push: - branches: [main, versions] - pull_request: branches: [main] jobs: - cancel-previous-runs: - runs-on: ubuntu-latest - - steps: - - name: Cancel previous runs of this workflow on same branch - uses: rokroskar/workflow-run-cleanup-action@v0.2.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - anylint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Export latest tool versions - run: | - latest_version() { - curl --silent "https://api.github.com/repos/$1/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' - } - echo "::set-env name=ANYLINT_LATEST_VERSION::$( latest_version Flinesoft/AnyLint )" - echo "::set-env name=SWIFT_SH_LATEST_VERSION::$( latest_version mxcl/swift-sh )" - - - name: AnyLint Cache - uses: actions/cache@v1 - id: anylint-cache - with: - path: anylint-cache - key: ${{ runner.os }}-v1-anylint-${{ env.ANYLINT_LATEST_VERSION }}-swift-sh-${{ env.SWIFT_SH_LATEST_VERSION }} - - - name: Copy from cache - if: steps.anylint-cache.outputs.cache-hit - run: | - sudo cp -f anylint-cache/anylint /usr/local/bin/anylint - sudo cp -f anylint-cache/swift-sh /usr/local/bin/swift-sh - - - name: Install AnyLint - if: steps.anylint-cache.outputs.cache-hit != 'true' - run: | - git clone https://github.com/Flinesoft/AnyLint.git - cd AnyLint - swift build -c release - sudo cp -f .build/release/anylint /usr/local/bin/anylint - - - name: Install swift-sh - if: steps.anylint-cache.outputs.cache-hit != 'true' - run: | - git clone https://github.com/mxcl/swift-sh.git - cd swift-sh - swift build -c release - sudo cp -f .build/release/swift-sh /usr/local/bin/swift-sh - - - name: Copy to cache - if: steps.anylint-cache.outputs.cache-hit != 'true' - run: | - mkdir -p anylint-cache - cp -f /usr/local/bin/anylint anylint-cache/anylint - cp -f /usr/local/bin/swift-sh anylint-cache/swift-sh - - - name: Cleanup checkouts - run: rm -rf AnyLint && rm -rf swift-sh - - - name: Run AnyLint - run: anylint - - swiftlint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Run SwiftLint - uses: norio-nomura/action-swiftlint@3.1.0 - with: - args: --strict - test-linux: runs-on: ubuntu-latest @@ -92,7 +14,6 @@ jobs: - name: Run tests run: swift test -v - test-macos: runs-on: macos-latest @@ -102,10 +23,15 @@ jobs: - name: Run tests run: swift test -v --enable-code-coverage - - name: Report Code Coverage - run: | - xcrun llvm-cov export -format="lcov" .build/debug/${PACKAGE_NAME}PackageTests.xctest/Contents/MacOS/${PACKAGE_NAME}PackageTests -instr-profile .build/debug/codecov/default.profdata > coverage.lcov - bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage.lcov - env: - PACKAGE_NAME: AnyLint - CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} + test-windows: + runs-on: windows-latest + + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v2 + + - name: Install Swift & Run tests + uses: MaxDesiatov/swift-windows-action@v1 + with: + shell-action: swift test -v diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..c8a4491 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,58 @@ +name: Pull Request + +on: + pull_request: + branches: [main] + +jobs: + cancel-previous-runs: + runs-on: ubuntu-latest + + steps: + - name: Cancel previous runs of this workflow on same branch + uses: rokroskar/workflow-run-cleanup-action@v0.2.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + anylint: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install AnyLint + run: brew tap Flinesoft/AnyLint https://github.com/Flinesoft/AnyLint.git && brew install anylint + + - name: Run AnyLint + run: anylint + + test-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run tests + run: swift test -v + + test-macos: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run tests + run: swift test -v --enable-code-coverage + + test-windows: + runs-on: windows-latest + + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v2 + + - name: Install Swift & Run tests + uses: MaxDesiatov/swift-windows-action@v1 + with: + shell-action: swift test -v diff --git a/.gitignore b/.gitignore index 81b332f..89bf5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,90 @@ +# macOS + +# General .DS_Store -/.build -/Packages -/*.xcodeproj +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +# Swift + +# Xcode +## User settings xcuserdata/ -/AnyLintTempTests -.codacy-coverage -*.lcov -codacy-coverage.json + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +Packages/ +Package.pins +Package.resolved +*.xcodeproj + +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm +.build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# project-specific +anylint-test-results.json diff --git a/.sourcery/LinuxMain.stencil b/.sourcery/LinuxMain.stencil deleted file mode 100644 index f5ff980..0000000 --- a/.sourcery/LinuxMain.stencil +++ /dev/null @@ -1,17 +0,0 @@ -@testable import AnyLintTests -@testable import Utility -import XCTest - -// swiftlint:disable line_length file_length - -{% for type in types.classes|based:"XCTestCase" %} -extension {{ type.name }} { - static var allTests: [(String, ({{ type.name }}) -> () throws -> Void)] = [ - {% for method in type.methods where method.parameters.count == 0 and method.shortName|hasPrefix:"test" and method|!annotated:"skipTestOnLinux" %} ("{{ method.shortName }}", {{ method.shortName }}){% if not forloop.last %},{% endif %} - {% endfor %}] -} - -{% endfor %} -XCTMain([ -{% for type in types.classes|based:"XCTestCase" %} testCase({{ type.name }}.allTests){% if not forloop.last %},{% endif %} -{% endfor %}]) diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..27735f7 --- /dev/null +++ b/.swift-format @@ -0,0 +1,15 @@ +{ + "lineBreakAroundMultilineExpressionChainComponents": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "lineLength": 120, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoLeadingUnderscores": true, + "ValidateDocumentationComments": true, + }, + "tabWidth": 2, + "version": 1, +} diff --git a/.swiftlint.yml b/.swiftlint.yml deleted file mode 100644 index b51b982..0000000 --- a/.swiftlint.yml +++ /dev/null @@ -1,122 +0,0 @@ -# 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 - - cyclomatic_complexity - -# 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/CHANGELOG.md b/CHANGELOG.md index 5663247..d4a009f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,15 +19,23 @@ If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries se ## [Unreleased] ### Added -- None. +- YAML-based configuration file. Supports `FilePaths` and `FileContents` as well as `CustomScripts` for full feature parity with previous Swift-based configuration file. + Author: [Cihat Gündüz](https://github.com/Jeehut) ### Changed -- None. +- Migrated from jakeheis/SwiftCLI to apple/swift-argument-parser for improved reliability & reduced maintenance. + Author: [Cihat Gündüz](https://github.com/Jeehut) +- Vastly improved configuration reading performance by migrating over to a YAML-based approach rather than a Swift config file. + Author: [Cihat Gündüz](https://github.com/Jeehut) ### Deprecated - None. ### Removed -- None. +- Swift-based configuration file support removed in favor of YAML-based configuration. All features supported via the Swift file still supported via YAML file. See README.md for more details. Parameters were not renamed to keep migration simple. + Author: [Cihat Gündüz](https://github.com/Jeehut) +- Support for Swift versions below 5.4 was dropped to make use of the latest improvements in Swift & SwiftPM. Use version `0.8.2` if you need to stay on lower Swift versions. + Author: [Cihat Gündüz](https://github.com/Jeehut) ### Fixed -- None. +- Issues with paths due to Swift scripting not being as easy to use now fixed by moving over to YAML-based configuration. For custom scripts, responsibility is moved to the user side by allowing to specify the exact command to run. + Author: [Cihat Gündüz](https://github.com/Jeehut) ### Security - None. @@ -101,7 +109,7 @@ If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries se ### Added - Made `AutoCorrection` expressible by Dictionary literals and updated the `README.md` accordingly. Issue: [#5](https://github.com/Flinesoft/AnyLint/issues/5) | PR: [#11](https://github.com/Flinesoft/AnyLint/pull/11) | Author: [Cihat Gündüz](https://github.com/Jeehut) -- Added option to skip checks within file contents by specifying `AnyLint.skipHere: ` or `AnyLint.skipInFile: `. Checkout the [Skip file content checks](https://github.com/Flinesoft/AnyLint#skip-file-content-checks) README section for more info. +- Added option to skip checks within file contents by specifying `AnyLint.skipHere: ` or `AnyLint.skipInFile: `. Checkout the [Skip file content checks](https://github.com/Flinesoft/AnyLint#skip-file-content-checks) README section for more info. Issue: [#9](https://github.com/Flinesoft/AnyLint/issues/9) | PR: [#12](https://github.com/Flinesoft/AnyLint/pull/12) | Author: [Cihat Gündüz](https://github.com/Jeehut) ## [0.2.0] - 2020-04-10 @@ -114,7 +122,7 @@ If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries se - Added two simple lint check examples in first code sample in README. (Thanks for the pointer, [Dave Verwer](https://github.com/daveverwer)!) Author: [Cihat Gündüz](https://github.com/Jeehut) ### Changed -- Changed `CheckInfo` id casing convention from snake_case to UpperCamelCase in `blank` template. +- Changed `Check` id casing convention from snake_case to UpperCamelCase in `blank` template. Author: [Cihat Gündüz](https://github.com/Jeehut) ## [0.1.0] - 2020-03-22 diff --git a/Formula/anylint.rb b/Formula/anylint.rb index 6a7fe13..dda76e0 100644 --- a/Formula/anylint.rb +++ b/Formula/anylint.rb @@ -1,11 +1,10 @@ class Anylint < Formula - desc "Lint anything by combining the power of Swift & regular expressions" + desc "Lint anything by combining the power of scripts & regular expressions" homepage "https://github.com/Flinesoft/AnyLint" url "https://github.com/Flinesoft/AnyLint.git", :tag => "0.8.2", :revision => "73cb2c9de3ed8e027fddf8df16e998c552dd7823" head "https://github.com/Flinesoft/AnyLint.git" - depends_on :xcode => ["11.4", :build] - depends_on "swift-sh" + depends_on :xcode => ["12.0", :build] def install system "make", "install", "prefix=#{prefix}" diff --git a/LICENSE b/LICENSE index b1efe33..0f84aa7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Flinesoft (alias Cihat Gündüz) +Copyright (c) 2020-2021 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 diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index aa38a47..0000000 --- a/Package.resolved +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 b266fdf..2fca0db 100644 --- a/Package.swift +++ b/Package.swift @@ -1,40 +1,93 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 import PackageDescription let package = Package( - name: "AnyLint", - products: [ - .library(name: "AnyLint", targets: ["AnyLint", "Utility"]), - .executable(name: "anylint", targets: ["AnyLintCLI"]), - ], - dependencies: [ - .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: [ - .target( - name: "AnyLint", - dependencies: ["Utility"] - ), - .testTarget( - name: "AnyLintTests", - dependencies: ["AnyLint"] - ), - .target( - name: "AnyLintCLI", - dependencies: ["Rainbow", "SwiftCLI", "Utility"] - ), - .testTarget( - name: "AnyLintCLITests", - dependencies: ["AnyLintCLI"] - ), - .target( - name: "Utility", - dependencies: ["Rainbow"] - ), - .testTarget( - name: "UtilityTests", - dependencies: ["Utility"] - ) - ] + name: "AnyLint", + platforms: [.macOS(.v10_15)], + products: [ + .executable(name: "anylint", targets: ["Commands"]), + ], + dependencies: [ + // Better Codable through Property Wrappers + .package(url: "https://github.com/marksands/BetterCodable.git", from: "0.4.0"), + + // Delightful console output for Swift developers. + .package(url: "https://github.com/onevcat/Rainbow.git", from: "4.0.0"), + + // Straightforward, type-safe argument parsing for Swift + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "0.4.3"), + + // Commonly used data structures for Swift` + .package(url: "https://github.com/apple/swift-collections.git", from: "0.0.3"), + + // A collection of tools for debugging, diffing, and testing your application's data structures. + .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "0.1.2"), + + // Easily run shell commands from a Swift script or command line tool + .package(url: "https://github.com/JohnSundell/ShellOut.git", from: "2.3.0"), + + // A Sweet and Swifty YAML parser. + .package(url: "https://github.com/jpsim/Yams.git", from: "4.0.6"), + ], + targets: [ + .target( + name: "Core", + dependencies: [ + .product(name: "BetterCodable", package: "BetterCodable"), + .product(name: "Rainbow", package: "Rainbow"), + ] + ), + .target( + name: "Checkers", + dependencies: [ + "Core", + "Reporting", + .product(name: "ShellOut", package: "ShellOut"), + ] + ), + .target( + name: "Configuration", + dependencies: [ + .product(name: "BetterCodable", package: "BetterCodable"), + "Core", + .product(name: "Yams", package: "Yams"), + ], + resources: [ + .copy("Templates"), + ] + ), + .target( + name: "Reporting", + dependencies: [ + "Core", + .product(name: "OrderedCollections", package: "swift-collections"), + ] + ), + .target( + name: "Commands", + dependencies: [ + "Checkers", + "Configuration", + "Core", + "Reporting", + .product(name: "ShellOut", package: "ShellOut"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + + // test targets + .target( + name: "TestSupport", + dependencies: [ + "Core", + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "Rainbow", package: "Rainbow"), + ] + ), + .testTarget(name: "CoreTests", dependencies: ["Core", "TestSupport"]), + .testTarget(name: "CheckersTests", dependencies: ["Checkers", "TestSupport"]), + .testTarget(name: "ConfigurationTests", dependencies: ["Configuration"]), + .testTarget(name: "ReportingTests", dependencies: ["Reporting", "TestSupport"]), + .testTarget(name: "CommandsTests", dependencies: ["Commands"]), + ] ) diff --git a/README.md b/README.md index 8cfcdc3..ca413c0 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,6 @@ CI - - Code Quality - - - Coverage - Version: 0.8.2 @@ -52,7 +44,7 @@ # AnyLint -Lint any project in any language using Swift and regular expressions. With built-in support for matching and non-matching examples validation & autocorrect replacement. Replaces SwiftLint custom rules & works for other languages as well! 🎉 +Lint anything by combining the power of scripts & regular expressions. With built-in support for matching and non-matching examples validation & autocorrect replacement. Replaces SwiftLint custom rules & works for/with other languages as well! 🎉 ## Installation @@ -84,62 +76,70 @@ mint install Flinesoft/AnyLint To initialize AnyLint in a project, run: ```bash -anylint --init blank +anylint init ``` -This will create the Swift script file `lint.swift` with something like the following contents: - -```swift -#!/usr/local/bin/swift-sh -import AnyLint // @Flinesoft - -Lint.logSummaryAndExit(arguments: CommandLine.arguments) { - // 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 the project.", - regex: readmeFile, - matchingExamples: ["README.md"], - nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], - violateIfNoMatchesFound: true - ) - - // MARK: ReadmeTypoLicense - try Lint.checkFileContents( - checkInfo: "ReadmeTypoLicense: Misspelled word 'license'.", - regex: #"([\s#]L|l)isence([\s\.,:;])"#, - matchingExamples: [" license:", "## Lisence\n"], - nonMatchingExamples: [" license:", "## License\n"], - includeFilters: [readmeFile], - autoCorrectReplacement: "$1icense$2", - autoCorrectExamples: [ - ["before": " lisence:", "after": " license:"], - ["before": "## Lisence\n", "after": "## License\n"], - ] - ) -} +This will create the Swift script file `anylint.yml` with something like the following contents: + +```yaml +FileContents: [] +# TODO: replace below sample checks with your custom checks and remove empty array specifier `[]` from above + # - id: ReadmeTypoLicense + # hint: 'ReadmeTypoLicense: Misspelled word `license`.' + # regex: '([\s#]L|l)isence([\s\.,:;])' + # matchingExamples: [' lisence:', "## Lisence\n"] + # nonMatchingExamples: [' license:', "## License\n"] + # includeFilters: ['^README\.md$'] + # autoCorrectReplacement: '$1icense$2' + # autoCorrectExamples: + # - { before: ' lisence:', after: ' license:' } + # - { before: "## Lisence\n", after: "## License\n" } + +FilePaths: [] +# TODO: replace below sample checks with your custom checks and remove empty array specifier `[]` from above + # - id: Readme + # hint: 'Each project should have a README.md file, explaining how to use or contribute to the project.' + # regex: '^README\.md$' + # violateIfNoMatchesFound: true + # matchingExamples: ['README.md'] + # nonMatchingExamples: ['README.markdown', 'Readme.md', 'ReadMe.md'] + # + # - id: ReadmePath + # hint: '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: + # - { before: 'api/readme.md', after: 'api/README.md' } + # - { before: 'ReadMe.md', after: 'README.md' } + # - { before: 'README.markdown', after: 'README.md' } + +CustomScripts: [] +# TODO: replace below sample check with your custom checks and remove empty array specifier `[]` from above + # - id: LintConfig + # hint: 'Lint the AnyLint config file to conform to YAML best practices.' + # command: |- + # if which yamllint > /dev/null; then + # yamllint anylint.yml + # else + # echo '{ "warning": { "YamlLint@warning: Not installed, see instructions at https://yamllint.readthedocs.io/en/stable/quickstart.html#installing-yamllint": [{}] } }' + # fi ``` -The most important thing to note is that the **first three lines are required** for AnyLint to work properly. - -All the other code 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 third line makes sure that all violations found in the process of running the code in the completion block are reported properly and exits the script with the proper exit code at the end. - 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 +anylint --init template=OpenSource --path Sources/anylint.yml --path Tests/anylint.yml ``` Runs the lint checks for both configuration files: ```bash -anylint --path Sources/lint.swift --path Tests/lint.swift +anylint --path Sources/anylint.yml --path Tests/anylint.yml ``` There are also several flags you can pass to `anylint`: @@ -154,11 +154,11 @@ There are also several flags you can pass to `anylint`: 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. +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. `CustomScripts`: Allows to write custom scripts in any language to do other kinds of checks. (TODO) -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). +Several examples of lint checks can be found in the [`anylint.yml` file of this very project](https://github.com/Flinesoft/AnyLint/blob/main/anylint.yml). ### Basic Types @@ -198,9 +198,9 @@ The `.anchorsMatchLines` option is always activated on literal usage as we stron -#### CheckInfo +#### Check -A `CheckInfo` contains the basic information about a lint check. It consists of: +A `Check` 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. @@ -210,8 +210,8 @@ While there is an initializer available, we recommend using a String Literal ins ```swift // accepted structure: (@): -let checkInfo: CheckInfo = "ReadmePath: The README file should be named exactly `README.md`." -let checkInfoCustomSeverity: CheckInfo = "ReadmePath@warning: The README file should be named exactly `README.md`." +let check: Check = "ReadmePath: The README file should be named exactly `README.md`." +let checkCustomSeverity: Check = "ReadmePath@warning: The README file should be named exactly `README.md`." ``` #### AutoCorrection @@ -234,12 +234,12 @@ let example: AutoCorrection = ["before": "Lisence", "after": "License"] 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`: +In its simplest form, the method just requires a `check` and a `regex`: ```swift // MARK: EmptyTodo try Lint.checkFileContents( - checkInfo: "EmptyTodo: TODO comments should not be empty.", + check: "EmptyTodo: TODO comments should not be empty.", regex: #"// TODO: *\n"# ) ``` @@ -264,7 +264,7 @@ let swiftTestFiles: Regex = #"Tests/.*\.swift"# // MARK: - Checks // MARK: empty_todo try Lint.checkFileContents( - checkInfo: "EmptyTodo: TODO comments should not be empty.", + check: "EmptyTodo: TODO comments should not be empty.", regex: #"// TODO: *\n"#, matchingExamples: ["// TODO:\n"], nonMatchingExamples: ["// TODO: not yet implemented\n"], @@ -295,7 +295,7 @@ 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.", + check: "EmptyMethodBody: Don't use whitespaces for the body of empty methods.", regex: [ "declaration": #"func [^\(\s]+\([^{]*\)"#, "spacing": #"\s*"#, @@ -340,7 +340,7 @@ While the `includeFilters` and `excludeFilters` arguments in the config file can For such cases, there are **2 ways to skip checks** within the files themselves: -1. `AnyLint.skipHere: `: Will skip the specified check(s) on the same line and the next line. +1. `AnyLint.skipHere: `: Will skip the specified check(s) on the same line and the next line. ```swift var x: Int = 5 // AnyLint.skipHere: MinVarNameLength @@ -351,7 +351,7 @@ For such cases, there are **2 ways to skip checks** within the files themselves: var x: Int = 5 ``` -2. `AnyLint.skipInFile: `: Will skip `All` or specificed check(s) in the entire file. +2. `AnyLint.skipInFile: `: Will skip `All` or specificed check(s) in the entire file. ```swift // AnyLint.skipInFile: MinVarNameLength @@ -387,12 +387,14 @@ By default, `checkFilePaths` will fail if the given `regex` matches a file. If y ### Custom Checks +TODO: Update to new custom script format supporting all languages as long as they output Violation JOSN format. + 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](https://swiftpm.co/). 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. +1. `check`: 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. +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 `Check` 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/HandySwift#regex) (the project, the class was taken from) or read the [code documentation comments](https://github.com/Flinesoft/AnyLint/blob/main/Sources/Utility/Regex.swift). @@ -409,7 +411,7 @@ Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: - Checks // MARK: LinuxMainUpToDate - try Lint.customCheck(checkInfo: "LinuxMainUpToDate: The tests in Tests/LinuxMain.swift should be up-to-date.") { checkInfo in + try Lint.customCheck(check: "LinuxMainUpToDate: The tests in Tests/LinuxMain.swift should be up-to-date.") { check in var violations: [Violation] = [] let linuxMainFilePath = "Tests/LinuxMain.swift" @@ -427,7 +429,7 @@ Lint.logSummaryAndExit(arguments: CommandLine.arguments) { if linuxMainContentsBeforeRegeneration != linuxMainContentsAfterRegeneration { violations.append( Violation( - checkInfo: checkInfo, + check: check, filePath: linuxMainFilePath, appliedAutoCorrection: AutoCorrection( before: linuxMainContentsBeforeRegeneration, @@ -492,6 +494,93 @@ Here are some **advanced Regex features** you might want to use or learn more ab See also [#3](https://github.com/Flinesoft/AnyLint/issues/3) +## YAML Cheat Sheet + +Please be aware that in YAML indentation (whitespaces) and newlines are actually important. +Natively supported types are: String, Integer, Float, Bool and Date. + +**Strings** (unlike in most other languages) don't need to be put between quotes, but can: +```yaml +string1: This is without quotes. +string2: 'This is with single quotes.' +string3: "This is with double quotes." +``` + +Note that only in double-quoted strings you can escape characters, e.g. `'Line1\nLine2'` will keep the `\n` as two characters in the result, whereas `"Line1\nLine2"` will escape `\n` to a newline. We recommend to use single quotes for `regex` arguments (the escaping will happen in the Regex parser) and double-quotes for any examples where you need escaping to be evaluated. + +**Multi-line strings** can be written by specifying `|` and then a newline: +```yaml +multiline1: | + This is a multi line string. + Newlines are going to be preserved. + By default, only one trailing newline is kept. +``` + +An additional `+` or `-` specified what to do with trailing newlines: +```yaml +multiline2: |+ + This will make sure both trailing newlines are kept (ends with ".\n\n"). + + +multiline3: |- + This will ignore any trailing newlines and + will end with the last non-newline character (the following dot in this case -->). + + +``` + +**Arrays** can be written in two ways: +```yaml +array1: [1, 2, 3] +array2: + - 1 + - 2 + - 3 +``` + +**Dictionaries**, too, can be written in two similar ways: +```yaml +array1: { key1: 1, key2: 2, key3: 3 } +array2: + - 1 + - 2 + - 3 +``` + +Dictionaries and Arrays can be nested indefinitely. Dictionaries within Arrays are denoted with one `-` before the keys: +```yaml +level1dict: + level2dict: + level3dict1: + leaf1: foo + leaf2: bar + level3dict2: + array1: [a, b, c] + array2: + - [a, b, c] + - [x, y, z] + level3array: + - level4dict1: a + level4dict2: b + level4dict3: c +``` + +You can also reuse Dictionaries defined earlier (at the top) down the file via `<<: *`: +```yaml +swiftFileFilters: + includeFilters: ['.*\.swift'] + excludeFilters: ['.*\.generated\.swift'] + +FileContents: + - id: Check1 + regex: 'a*b*' + <<: *swiftFileFilters + + - id: Check2 + regex: 'c*d*' + <<: *swiftFileFilters +``` + ## Donation AnyLint was brought to you by [Cihat Gündüz](https://github.com/Jeehut) in his free time. If you want to thank me and support the development of this project, please **make a small donation on [PayPal](https://paypal.me/Dschee/5EUR)**. In case you also like my other [open source contributions](https://github.com/Flinesoft) and [articles](https://medium.com/@Jeehut), please consider motivating me by **becoming a sponsor on [GitHub](https://github.com/sponsors/Jeehut)** or a **patron on [Patreon](https://www.patreon.com/Jeehut)**. diff --git a/Sources/AnyLint/AutoCorrection.swift b/Sources/AnyLint/AutoCorrection.swift deleted file mode 100644 index eff4213..0000000 --- a/Sources/AnyLint/AutoCorrection.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import Utility - -/// 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] { - if useDiffOutput, #available(OSX 10.15, *) { - var lines: [String] = ["Autocorrection applied, the diff is: (+ added, - removed)"] - - let beforeLines = before.components(separatedBy: .newlines) - let afterLines = after.components(separatedBy: .newlines) - - for difference in afterLines.difference(from: beforeLines).sorted() { - switch difference { - case let .insert(offset, element, _): - lines.append("+ [L\(offset + 1)] \(element)".green) - - case let .remove(offset, element, _): - lines.append("- [L\(offset + 1)] \(element)".red) - } - } - - return lines - } else { - return [ - "Autocorrection applied, the diff is: (+ added, - removed)", - "- \(before.showWhitespacesAndNewlines())".red, - "+ \(after.showWhitespacesAndNewlines())".green, - ] - } - } - - var useDiffOutput: Bool { - before.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing || - after.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing - } - - /// Initializes an autocorrection. - public init(before: String, after: String) { - self.before = before - self.after = after - } -} - -extension AutoCorrection: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (String, String)...) { - guard - let before = elements.first(where: { $0.0 == "before" })?.1, - let after = elements.first(where: { $0.0 == "after" })?.1 - else { - log.message("Failed to convert Dictionary literal '\(elements)' to type AutoCorrection.", level: .error) - log.exit(status: .failure) - exit(EXIT_FAILURE) // only reachable in unit tests - } - - self = AutoCorrection(before: before, after: after) - } -} - -// TODO: make the autocorrection diff sorted by line number -@available(OSX 10.15, *) -extension CollectionDifference.Change: Comparable where ChangeElement == String { - public static func < (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): - return leftOffset < rightOffset - - case let (.remove(leftOffset, _, _), .insert(rightOffset, _, _)): - return leftOffset < rightOffset || true - - case let (.insert(leftOffset, _, _), .remove(rightOffset, _, _)): - return leftOffset < rightOffset || false - } - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): - return leftOffset == rightOffset - - case (.remove, .insert), (.insert, .remove): - return false - } - } -} diff --git a/Sources/AnyLint/CheckInfo.swift b/Sources/AnyLint/CheckInfo.swift deleted file mode 100644 index 9cd3080..0000000 --- a/Sources/AnyLint/CheckInfo.swift +++ /dev/null @@ -1,79 +0,0 @@ -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: CustomStringConvertible { - public var description: String { - "check '\(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 deleted file mode 100644 index d0c0f56..0000000 --- a/Sources/AnyLint/Checkers/Checker.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -protocol Checker { - func performCheck() throws -> [Violation] -} diff --git a/Sources/AnyLint/Checkers/FileContentsChecker.swift b/Sources/AnyLint/Checkers/FileContentsChecker.swift deleted file mode 100644 index 5b03e2e..0000000 --- a/Sources/AnyLint/Checkers/FileContentsChecker.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import Utility - -struct FileContentsChecker { - let checkInfo: CheckInfo - let regex: Regex - let filePathsToCheck: [String] - let autoCorrectReplacement: String? - let repeatIfAutoCorrected: Bool -} - -extension FileContentsChecker: Checker { - func performCheck() throws -> [Violation] { // swiftlint:disable:this function_body_length - log.message("Start checking \(checkInfo) ...", level: .debug) - var violations: [Violation] = [] - - for filePath in filePathsToCheck.reversed() { - log.message("Start reading contents of file at \(filePath) ...", level: .debug) - - if let fileData = fileManager.contents(atPath: filePath), let fileContents = String(data: fileData, encoding: .utf8) { - var newFileContents: String = fileContents - let linesInFile: [String] = fileContents.components(separatedBy: .newlines) - - // skip check in file if contains `AnyLint.skipInFile: ` - let skipInFileRegex = try Regex(#"AnyLint\.skipInFile:[^\n]*([, ]All[,\s]|[, ]\#(checkInfo.id)[,\s])"#) - guard !skipInFileRegex.matches(fileContents) else { - log.message("Skipping \(checkInfo) in file \(filePath) due to 'AnyLint.skipInFile' instruction ...", level: .debug) - continue - } - - let skipHereRegex = try Regex(#"AnyLint\.skipHere:[^\n]*[, ]\#(checkInfo.id)"#) - - for match in regex.matches(in: fileContents).reversed() { - let locationInfo = fileContents.locationInfo(of: match.range.lowerBound) - - log.message("Found violating match at \(locationInfo) ...", level: .debug) - - // skip found match if contains `AnyLint.skipHere: ` in same line or one line before - guard !linesInFile.containsLine(at: [locationInfo.line - 2, locationInfo.line - 1], matchingRegex: skipHereRegex) else { - log.message("Skip reporting last match due to 'AnyLint.skipHere' instruction ...", level: .debug) - continue - } - - let autoCorrection: AutoCorrection? = { - guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } - - let newMatchString = regex.replaceAllCaptures(in: match.string, with: autoCorrectReplacement) - return AutoCorrection(before: match.string, after: newMatchString) - }() - - if let autoCorrection = autoCorrection { - guard match.string != autoCorrection.after else { - // can skip auto-correction & violation reporting because auto-correct replacement is equal to matched string - continue - } - - // apply auto correction - newFileContents.replaceSubrange(match.range, with: autoCorrection.after) - log.message("Applied autocorrection for last match ...", level: .debug) - } - - log.message("Reporting violation for \(checkInfo) in file \(filePath) at \(locationInfo) ...", level: .debug) - violations.append( - Violation( - checkInfo: checkInfo, - filePath: filePath, - matchedString: match.string, - locationInfo: locationInfo, - appliedAutoCorrection: autoCorrection - ) - ) - } - - if newFileContents != fileContents { - log.message("Rewriting contents of file \(filePath) due to autocorrection changes ...", level: .debug) - 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 - ) - } - - Statistics.shared.checkedFiles(at: [filePath]) - } - - violations = violations.reversed() - - if repeatIfAutoCorrected && violations.contains(where: { $0.appliedAutoCorrection != nil }) { - log.message("Repeating check \(checkInfo) because auto-corrections were applied on last run.", level: .debug) - - // only paths where auto-corrections were applied need to be re-checked - let filePathsToReCheck = Array(Set(violations.filter { $0.appliedAutoCorrection != nil }.map { $0.filePath! })).sorted() - - let violationsOnRechecks = try FileContentsChecker( - checkInfo: checkInfo, - regex: regex, - filePathsToCheck: filePathsToReCheck, - autoCorrectReplacement: autoCorrectReplacement, - repeatIfAutoCorrected: repeatIfAutoCorrected - ).performCheck() - violations.append(contentsOf: violationsOnRechecks) - } - - return violations - } -} diff --git a/Sources/AnyLint/Checkers/FilePathsChecker.swift b/Sources/AnyLint/Checkers/FilePathsChecker.swift deleted file mode 100644 index c88c77f..0000000 --- a/Sources/AnyLint/Checkers/FilePathsChecker.swift +++ /dev/null @@ -1,52 +0,0 @@ -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 { - log.message("Reporting violation for \(checkInfo) as no matching file was found ...", level: .debug) - violations.append( - Violation(checkInfo: checkInfo, filePath: nil, locationInfo: nil, appliedAutoCorrection: nil) - ) - } - } else { - for filePath in filePathsToCheck where regex.matches(filePath) { - log.message("Found violating match for \(checkInfo) ...", level: .debug) - - 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) - }() - - if appliedAutoCorrection != nil { - log.message("Applied autocorrection for last match ...", level: .debug) - } - - log.message("Reporting violation for \(checkInfo) in file \(filePath) ...", level: .debug) - violations.append( - Violation(checkInfo: checkInfo, filePath: filePath, locationInfo: nil, appliedAutoCorrection: appliedAutoCorrection) - ) - } - - Statistics.shared.checkedFiles(at: filePathsToCheck) - } - - return violations - } -} diff --git a/Sources/AnyLint/Extensions/ArrayExt.swift b/Sources/AnyLint/Extensions/ArrayExt.swift deleted file mode 100644 index 6067e9f..0000000 --- a/Sources/AnyLint/Extensions/ArrayExt.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -extension Array where Element == String { - func containsLine(at indexes: [Int], matchingRegex regex: Regex) -> Bool { - indexes.contains { index in - guard index >= 0, index < count else { return false } - return regex.matches(self[index]) - } - } -} diff --git a/Sources/AnyLint/Extensions/FileManagerExt.swift b/Sources/AnyLint/Extensions/FileManagerExt.swift deleted file mode 100644 index b6ec5f2..0000000 --- a/Sources/AnyLint/Extensions/FileManagerExt.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import Utility - -extension FileManager { - /// 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) - } - - FilesSearch.shared.invalidateCache() - } -} diff --git a/Sources/AnyLint/Extensions/StringExt.swift b/Sources/AnyLint/Extensions/StringExt.swift deleted file mode 100644 index 7bcc39d..0000000 --- a/Sources/AnyLint/Extensions/StringExt.swift +++ /dev/null @@ -1,32 +0,0 @@ -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) - - /// Returns the location info for a given line index. - public 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"#) - } - - func showWhitespaces() -> String { - components(separatedBy: .whitespaces).joined(separator: "␣") - } - - func showWhitespacesAndNewlines() -> String { - showNewlines().showWhitespaces() - } -} diff --git a/Sources/AnyLint/Extensions/URLExt.swift b/Sources/AnyLint/Extensions/URLExt.swift deleted file mode 100644 index 67f5394..0000000 --- a/Sources/AnyLint/Extensions/URLExt.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation -import Utility - -extension URL { - /// Returns the relative path of from the current path. - public var relativePathFromCurrent: String { - String(path.replacingOccurrences(of: fileManager.currentDirectoryPath, with: "").dropFirst()) - } -} diff --git a/Sources/AnyLint/FilesSearch.swift b/Sources/AnyLint/FilesSearch.swift deleted file mode 100644 index e100fca..0000000 --- a/Sources/AnyLint/FilesSearch.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import Utility - -/// Helper to search for files and filter using Regexes. -public final class FilesSearch { - struct SearchOptions: Equatable, Hashable { - let pathToSearch: String - let includeFilters: [Regex] - let excludeFilters: [Regex] - } - - /// The shared instance. - public static let shared = FilesSearch() - - private var cachedFilePaths: [SearchOptions: [String]] = [:] - - private init() {} - - /// Should be called whenever files within the current directory are renamed, moved, added or deleted. - func invalidateCache() { - cachedFilePaths = [:] - } - - /// Returns all file paths within given `path` matching the given `include` and `exclude` filters. - public func allFiles( // swiftlint:disable:this function_body_length - within path: String, - includeFilters: [Regex], - excludeFilters: [Regex] = [] - ) -> [String] { - log.message( - "Start searching for matching files in path \(path) with includeFilters \(includeFilters) and excludeFilters \(excludeFilters) ...", - level: .debug - ) - - let searchOptions = SearchOptions(pathToSearch: path, includeFilters: includeFilters, excludeFilters: excludeFilters) - if let cachedFilePaths: [String] = cachedFilePaths[searchOptions] { - log.message("A file search with exactly the above search options was already done and was not invalidated, using cached results ...", level: .debug) - return cachedFilePaths - } - - 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 - } - - let propKeys = [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey] - guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: propKeys, 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 os(Linux) - if isHiddenFilePath || fileUrl.path.contains("/.") || fileUrl.path.starts(with: ".") { - if !isRegularFilePath { - enumerator.skipDescendants() - } - - continue - } - #else - if isHiddenFilePath { - if !isRegularFilePath { - enumerator.skipDescendants() - } - - continue - } - #endif - - guard isRegularFilePath, includeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) else { continue } - - filePaths.append(fileUrl.relativePathFromCurrent) - } - - cachedFilePaths[searchOptions] = filePaths - return filePaths - } -} diff --git a/Sources/AnyLint/Lint.swift b/Sources/AnyLint/Lint.swift deleted file mode 100644 index 463d8e1..0000000 --- a/Sources/AnyLint/Lint.swift +++ /dev/null @@ -1,254 +0,0 @@ -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. - /// - repeatIfAutoCorrected: Repeat check if at least one auto-correction was applied in last run. Defaults to `false`. - public static func checkFileContents( - checkInfo: CheckInfo, - regex: Regex, - matchingExamples: [String] = [], - nonMatchingExamples: [String] = [], - includeFilters: [Regex] = [#".*"#], - excludeFilters: [Regex] = [], - autoCorrectReplacement: String? = nil, - autoCorrectExamples: [AutoCorrection] = [], - repeatIfAutoCorrected: 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: nil - ) - - if let autoCorrectReplacement = autoCorrectReplacement { - validateAutocorrectsAll( - checkInfo: checkInfo, - examples: autoCorrectExamples, - regex: regex, - autocorrectReplacement: autoCorrectReplacement - ) - } - - guard !Options.validateOnly else { - Statistics.shared.executedChecks.append(checkInfo) - return - } - - let filePathsToCheck: [String] = FilesSearch.shared.allFiles( - within: fileManager.currentDirectoryPath, - includeFilters: includeFilters, - excludeFilters: excludeFilters - ) - - let violations = try FileContentsChecker( - checkInfo: checkInfo, - regex: regex, - filePathsToCheck: filePathsToCheck, - autoCorrectReplacement: autoCorrectReplacement, - repeatIfAutoCorrected: repeatIfAutoCorrected - ).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 - ) - } - - guard !Options.validateOnly else { - Statistics.shared.executedChecks.append(checkInfo) - return - } - - let filePathsToCheck: [String] = FilesSearch.shared.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: (CheckInfo) -> [Violation]) { - guard !Options.validateOnly else { - Statistics.shared.executedChecks.append(checkInfo) - return - } - - Statistics.shared.found(violations: customClosure(checkInfo), 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(arguments: [String] = [], afterPerformingChecks checksToPerform: () throws -> Void = {}) throws { - let failOnWarnings = arguments.contains(Constants.strictArgument) - let targetIsXcode = arguments.contains(Logger.OutputType.xcode.rawValue) - - if targetIsXcode { - log = Logger(outputType: .xcode) - } - - log.logDebugLevel = arguments.contains(Constants.debugArgument) - Options.validateOnly = arguments.contains(Constants.validateArgument) - - try checksToPerform() - - guard !Options.validateOnly else { - Statistics.shared.logValidationSummary() - log.exit(status: .success) - return // only reachable in unit tests - } - - Statistics.shared.logCheckSummary() - - if Statistics.shared.violations(severity: .error, excludeAutocorrected: targetIsXcode).isFilled { - log.exit(status: .failure) - } else if failOnWarnings && Statistics.shared.violations(severity: .warning, excludeAutocorrected: targetIsXcode).isFilled { - log.exit(status: .failure) - } else { - log.exit(status: .success) - } - } - - static func validate(regex: Regex, matchesForEach matchingExamples: [String], checkInfo: CheckInfo) { - if matchingExamples.isFilled { - log.message("Validating 'matchingExamples' for \(checkInfo) ...", level: .debug) - } - - for example in matchingExamples { - if !regex.matches(example) { - 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) { - if nonMatchingExamples.isFilled { - log.message("Validating 'nonMatchingExamples' for \(checkInfo) ...", level: .debug) - } - - for example in nonMatchingExamples { - if regex.matches(example) { - 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) { - if examples.isFilled { - log.message("Validating 'autoCorrectExamples' for \(checkInfo) ...", level: .debug) - } - - 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.showWhitespacesAndNewlines())' - After: '\(autocorrected.showWhitespacesAndNewlines())' - Expected: '\(autocorrect.after.showWhitespacesAndNewlines())' - """, - 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/Options.swift b/Sources/AnyLint/Options.swift deleted file mode 100644 index db20a7d..0000000 --- a/Sources/AnyLint/Options.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -enum Options { - static var validateOnly: Bool = false -} diff --git a/Sources/AnyLint/Severity.swift b/Sources/AnyLint/Severity.swift deleted file mode 100644 index 195c8a0..0000000 --- a/Sources/AnyLint/Severity.swift +++ /dev/null @@ -1,49 +0,0 @@ -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 - } - } -} - -extension Severity: Comparable { - public static func < (lhs: Severity, rhs: Severity) -> Bool { - lhs.rawValue < rhs.rawValue - } -} diff --git a/Sources/AnyLint/Statistics.swift b/Sources/AnyLint/Statistics.swift deleted file mode 100644 index ad79441..0000000 --- a/Sources/AnyLint/Statistics.swift +++ /dev/null @@ -1,140 +0,0 @@ -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 filesChecked: Set = [] - - var maxViolationSeverity: Severity? { - violationsBySeverity.keys.filter { !violationsBySeverity[$0]!.isEmpty }.max { $0.rawValue < $1.rawValue } - } - - private init() {} - - func checkedFiles(at filePaths: [String]) { - filePaths.forEach { filesChecked.insert($0) } - } - - 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: []] - filesChecked = [] - } - - func logValidationSummary() { - guard log.outputType != .xcode else { - log.message("Performing validations only while reporting for Xcode is probably misuse of the `-l` / `--validate` option.", level: .warning) - return - } - - if executedChecks.isEmpty { - log.message("No checks found to validate.", level: .warning) - } else { - log.message( - "Performed \(executedChecks.count) validation(s) in \(filesChecked.count) file(s) without any issues.", - level: .success - ) - } - } - - func logCheckSummary() { - if executedChecks.isEmpty { - log.message("No checks found to perform.", level: .warning) - } else if violationsBySeverity.values.contains(where: { $0.isFilled }) { - switch log.outputType { - case .console, .test: - logViolationsToConsole() - - case .xcode: - showViolationsInXcode() - } - } else { - log.message( - "Performed \(executedChecks.count) check(s) in \(filesChecked.count) file(s) without any violations.", - level: .success - ) - } - } - - func violations(severity: Severity, excludeAutocorrected: Bool) -> [Violation] { - let violations: [Violation] = violationsBySeverity[severity]! - guard excludeAutocorrected else { return violations } - return violations.filter { $0.appliedAutoCorrection == nil } - } - - private func logViolationsToConsole() { - for check in executedChecks { - if let checkViolations = violationsPerCheck[check], checkViolations.isFilled { - let violationsWithLocationMessage = checkViolations.filter { $0.locationMessage(pathType: .relative) != nil } - - if violationsWithLocationMessage.isFilled { - log.message( - "\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s) at:", - level: check.severity.logLevel - ) - let numerationDigits = String(violationsWithLocationMessage.count).count - - for (index, violation) in violationsWithLocationMessage.enumerated() { - let violationNumString = String(format: "%0\(numerationDigits)d", index + 1) - let prefix = "> \(violationNumString). " - log.message(prefix + violation.locationMessage(pathType: .relative)!, level: check.severity.logLevel) - - let prefixLengthWhitespaces = (0 ..< prefix.count).map { _ in " " }.joined() - if let appliedAutoCorrection = violation.appliedAutoCorrection { - for messageLine in appliedAutoCorrection.appliedMessageLines { - log.message(prefixLengthWhitespaces + messageLine, level: .info) - } - } else if let matchedString = violation.matchedString { - log.message(prefixLengthWhitespaces + "Matching string:".bold + " (trimmed & reduced whitespaces)", level: .info) - let matchedStringOutput = matchedString - .showNewlines() - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: " ", with: " ") - log.message(prefixLengthWhitespaces + "> " + matchedStringOutput, level: .info) - } - } - } else { - log.message("\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s).", level: check.severity.logLevel) - } - - log.message(">> Hint: \(check.hint)".bold.italic, level: check.severity.logLevel) - } - } - - let errors = "\(violationsBySeverity[.error]!.count) error(s)" - let warnings = "\(violationsBySeverity[.warning]!.count) warning(s)" - - log.message( - "Performed \(executedChecks.count) check(s) in \(filesChecked.count) file(s) and found \(errors) & \(warnings).", - level: maxViolationSeverity!.logLevel - ) - } - - private func showViolationsInXcode() { - for severity in violationsBySeverity.keys.sorted().reversed() { - let severityViolations = violationsBySeverity[severity]! - for violation in severityViolations where violation.appliedAutoCorrection == nil { - let check = violation.checkInfo - log.xcodeMessage( - "[\(check.id)] \(check.hint)", - level: check.severity.logLevel, - location: violation.locationMessage(pathType: .absolute) - ) - } - } - } -} diff --git a/Sources/AnyLint/Violation.swift b/Sources/AnyLint/Violation.swift deleted file mode 100644 index 6eec576..0000000 --- a/Sources/AnyLint/Violation.swift +++ /dev/null @@ -1,43 +0,0 @@ -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? - - /// Initializes a violation object. - public 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 - } - - /// Returns a string representation of a violations filled with path and line information if available. - public func locationMessage(pathType: String.PathType) -> String? { - guard let filePath = filePath else { return nil } - guard let locationInfo = locationInfo else { return filePath.path(type: pathType) } - return "\(filePath.path(type: pathType)):\(locationInfo.line):\(locationInfo.charInLine):" - } -} diff --git a/Sources/AnyLintCLI/Commands/SingleCommand.swift b/Sources/AnyLintCLI/Commands/SingleCommand.swift deleted file mode 100644 index e1b1b45..0000000 --- a/Sources/AnyLintCLI/Commands/SingleCommand.swift +++ /dev/null @@ -1,76 +0,0 @@ -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: "Prints the current tool version") - var version: Bool - - @Flag("-x", "--xcode", description: "Prints warnings & errors in a format to be reported right within Xcodes left sidebar") - var xcode: Bool - - @Flag("-d", "--debug", description: "Logs much more detailed information about what AnyLint is doing for debugging purposes") - var debug: Bool - - @Flag("-s", "--strict", description: "Fails on warnings as well - by default, the command only fails on errors)") - var strict: Bool - - @Flag("-l", "--validate", description: "Runs only validations for `matchingExamples`, `nonMatchingExamples` and `autoCorrectExamples`.") - var validate: 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 { - if xcode { - log = Logger(outputType: .xcode) - } - - log.logDebugLevel = debug - - // 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, logDebugLevel: debug, failOnWarnings: strict, validateOnly: validate).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 deleted file mode 100644 index 13b2d97..0000000 --- a/Sources/AnyLintCLI/ConfigurationTemplates/BlankTemplate.swift +++ /dev/null @@ -1,78 +0,0 @@ -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: readmeFile, - 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: [ - ["before": "api/readme.md", "after": "api/README.md"], - ["before": "ReadMe.md", "after": "README.md"], - ["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: [ - ["before": " lisence:", "after": " license:"], - ["before": "## Lisence\n", "after": "## License\n"], - ] - ) - """# + commonSuffix - } -} diff --git a/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift b/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift deleted file mode 100644 index 76b9122..0000000 --- a/Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import Utility - -protocol ConfigurationTemplate { - static func fileContents() -> String -} - -extension ConfigurationTemplate { - static var commonPrefix: String { - """ - #!\(CLIConstants.swiftShPath) - import AnyLint // @Flinesoft - - try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { - - """ - } - - static var commonSuffix: String { - """ - - } - - """ - } -} diff --git a/Sources/AnyLintCLI/Globals/CLIConstants.swift b/Sources/AnyLintCLI/Globals/CLIConstants.swift deleted file mode 100644 index 07111e3..0000000 --- a/Sources/AnyLintCLI/Globals/CLIConstants.swift +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index f83a5cb..0000000 --- a/Sources/AnyLintCLI/Globals/ValidateOrFail.swift +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index dbcf06f..0000000 --- a/Sources/AnyLintCLI/Tasks/InitTask.swift +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 949b553..0000000 --- a/Sources/AnyLintCLI/Tasks/LintTask.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import SwiftCLI -import Utility - -struct LintTask { - let configFilePath: String - let logDebugLevel: Bool - let failOnWarnings: Bool - let validateOnly: Bool -} - -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) - - var command = "\(configFilePath.absolutePath) \(log.outputType.rawValue)" - - if logDebugLevel { - command += " \(Constants.debugArgument)" - } - - if failOnWarnings { - command += " \(Constants.strictArgument)" - } - - if validateOnly { - command += " \(Constants.validateArgument)" - } - - try Task.run(bash: command) - log.message("Linting successful using config file at \(configFilePath). Congrats! 🎉", level: .success) - } catch is RunError { - if log.outputType != .xcode { - 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 deleted file mode 100644 index 9986c03..0000000 --- a/Sources/AnyLintCLI/Tasks/TaskHandler.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -protocol TaskHandler { - func perform() throws -} diff --git a/Sources/AnyLintCLI/Tasks/VersionTask.swift b/Sources/AnyLintCLI/Tasks/VersionTask.swift deleted file mode 100644 index e043f26..0000000 --- a/Sources/AnyLintCLI/Tasks/VersionTask.swift +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index d4cc92b..0000000 --- a/Sources/AnyLintCLI/main.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation -import SwiftCLI - -let singleCommand = CLI(singleCommand: SingleCommand()) -singleCommand.goAndExit() diff --git a/Sources/Checkers/Checker.swift b/Sources/Checkers/Checker.swift new file mode 100644 index 0000000..351c0c4 --- /dev/null +++ b/Sources/Checkers/Checker.swift @@ -0,0 +1,8 @@ +import Foundation +import Core + +/// Defines how a checker algorithm behaves to produce violations results. +public protocol Checker { + /// Executes the checks and returns violations (if any). + func performCheck() throws -> [Violation] +} diff --git a/Sources/Checkers/Extensions/ArrayExt.swift b/Sources/Checkers/Extensions/ArrayExt.swift new file mode 100644 index 0000000..512ee3f --- /dev/null +++ b/Sources/Checkers/Extensions/ArrayExt.swift @@ -0,0 +1,11 @@ +import Foundation +import Core + +extension Array where Element == String { + func containsLine(at indexes: [Int], matchingRegex regex: Regex) -> Bool { + indexes.contains { index in + guard index >= 0, index < count else { return false } + return regex.matches(self[index]) + } + } +} diff --git a/Sources/Checkers/Extensions/FileManagerExt.swift b/Sources/Checkers/Extensions/FileManagerExt.swift new file mode 100644 index 0000000..1fdf065 --- /dev/null +++ b/Sources/Checkers/Extensions/FileManagerExt.swift @@ -0,0 +1,39 @@ +import Foundation +import Core + +extension FileManager { + /// 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(fail: true) + } + + 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(fail: true) + } + + 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) + } + + FilesSearch.shared.invalidateCache() + } +} diff --git a/Sources/Checkers/Extensions/RegexExt.swift b/Sources/Checkers/Extensions/RegexExt.swift new file mode 100644 index 0000000..b913435 --- /dev/null +++ b/Sources/Checkers/Extensions/RegexExt.swift @@ -0,0 +1,33 @@ +import Foundation +import Core + +extension Regex { + /// Constants to reference across the project related to Regexes. + enum Constants { + /// The separator indicating that next come regex options. + static let regexOptionsSeparator: String = #"\"# + + /// Hint that the case insensitive option should be active on a Regex. + static let caseInsensitiveRegexOption: String = "i" + + /// Hint that the case dot matches newline option should be active on a Regex. + static let dotMatchesNewlinesRegexOption: String = "m" + } +} + +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 = try! 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/Checkers/FileContentsChecker.swift b/Sources/Checkers/FileContentsChecker.swift new file mode 100644 index 0000000..bff6037 --- /dev/null +++ b/Sources/Checkers/FileContentsChecker.swift @@ -0,0 +1,115 @@ +import Foundation +import Core + +/// The checker for the `FileContents` configuration. Runs regex-based chacks on contents of files. +public struct FileContentsChecker { + /// 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 + + /// The regular expression to use. + public let regex: Regex + + /// The file paths to check. + public let filePathsToCheck: [String] + + /// The optional replacement template string for replacing using capture groups. + public let autoCorrectReplacement: String? + + /// If set to `true`, the contents check will be repeated until there are no longer any changes when applying autocorrection. + public let repeatIfAutoCorrected: Bool +} + +extension FileContentsChecker: Checker { + public func performCheck() throws -> [Violation] { + var violations: [Violation] = [] + + for filePath in filePathsToCheck.reversed() { + if let fileData = FileManager.default.contents(atPath: filePath), + let fileContents = String(data: fileData, encoding: .utf8) + { + var newFileContents: String = fileContents + let linesInFile: [String] = fileContents.components(separatedBy: .newlines) + + // skip check in file if contains `AnyLint.skipInFile: ` + let skipInFileRegex = try Regex(#"AnyLint\.skipInFile:[^\n]*([, ]All[,\s]|[, ]\#(id)[,\s])"#) + guard !skipInFileRegex.matches(fileContents) else { continue } + + let skipHereRegex = try Regex(#"AnyLint\.skipHere:[^\n]*[, ]\#(id)"#) + + for match in regex.matches(in: fileContents).reversed() { + let location = fileContents.fileLocation(of: match.range.lowerBound, filePath: filePath) + + // skip found match if contains `AnyLint.skipHere: ` in same line or one line before + guard + !linesInFile.containsLine(at: [location.row! - 2, location.row! - 1], matchingRegex: skipHereRegex) + else { continue } + + let autoCorrection: AutoCorrection? = { + guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } + + let newMatchString = regex.replaceAllCaptures(in: match.string, with: autoCorrectReplacement) + return AutoCorrection(before: match.string, after: newMatchString) + }() + + if let autoCorrection = autoCorrection { + guard match.string != autoCorrection.after else { + // can skip auto-correction & violation reporting because auto-correct replacement is equal to matched string + continue + } + + // apply auto correction + newFileContents.replaceSubrange(match.range, with: autoCorrection.after) + } + + violations.append( + Violation( + matchedString: match.string, + location: location, + appliedAutoCorrection: autoCorrection + ) + ) + } + + 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 + ) + } + } + + violations = violations.reversed() + + if repeatIfAutoCorrected && violations.contains(where: { $0.appliedAutoCorrection != nil }) { + // only paths where auto-corrections were applied need to be re-checked + let filePathsToReCheck = Array( + Set(violations.filter { $0.appliedAutoCorrection != nil }.map { $0.location!.filePath }) + ) + .sorted() + + let violationsOnRechecks = try FileContentsChecker( + id: id, + hint: hint, + severity: severity, + regex: regex, + filePathsToCheck: filePathsToReCheck, + autoCorrectReplacement: autoCorrectReplacement, + repeatIfAutoCorrected: repeatIfAutoCorrected + ) + .performCheck() + violations.append(contentsOf: violationsOnRechecks) + } + + return violations + } +} diff --git a/Sources/Checkers/FilePathsChecker.swift b/Sources/Checkers/FilePathsChecker.swift new file mode 100644 index 0000000..729d686 --- /dev/null +++ b/Sources/Checkers/FilePathsChecker.swift @@ -0,0 +1,62 @@ +import Foundation +import Core + +/// The checker for the `FilePaths` configuration. Runs regex-based chacks on file paths. +public struct FilePathsChecker { + /// 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 + + /// The regular expression to use. + public let regex: Regex + + /// The file paths to check. + public let filePathsToCheck: [String] + + /// The optional replacement template string for replacing using capture groups. + public let autoCorrectReplacement: String? + + /// If set to `true`, then the check will violate if no matches found, otherwise it will report every match as a violation. + public let violateIfNoMatchesFound: Bool +} + +extension FilePathsChecker: Checker { + public func performCheck() throws -> [Violation] { + var violations: [Violation] = [] + + if violateIfNoMatchesFound { + let matchingFilePathsCount = filePathsToCheck.filter { regex.matches($0) }.count + if matchingFilePathsCount <= 0 { + violations.append( + Violation(location: 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.default.moveFileSafely(from: filePath, to: newFilePath) + + return AutoCorrection(before: filePath, after: newFilePath) + }() + + violations.append( + Violation( + location: .init(filePath: filePath), + appliedAutoCorrection: appliedAutoCorrection + ) + ) + } + } + + return violations + } +} diff --git a/Sources/Checkers/FilesSearch.swift b/Sources/Checkers/FilesSearch.swift new file mode 100644 index 0000000..0a47633 --- /dev/null +++ b/Sources/Checkers/FilesSearch.swift @@ -0,0 +1,111 @@ +import Foundation +import Core + +/// Helper to search for files and filter using Regexes. +public final class FilesSearch { + struct SearchOptions: Equatable, Hashable { + let pathToSearch: String + let includeFilters: [Regex] + let excludeFilters: [Regex] + } + + /// The shared instance. + public static let shared = FilesSearch() + + private var cachedFilePaths: [SearchOptions: [String]] = [:] + + private init() {} + + /// Should be called whenever files within the current directory are renamed, moved, added or deleted. + func invalidateCache() { + cachedFilePaths = [:] + } + + /// Returns all file paths within given `path` matching the given `include` and `exclude` filters. + public func allFiles( + within path: String, + includeFilters: [Regex], + excludeFilters: [Regex] = [] + ) -> [String] { + let searchOptions = SearchOptions( + pathToSearch: path, + includeFilters: includeFilters, + excludeFilters: excludeFilters + ) + + // AnyLint.skipHere: IfAsGuard + if let cachedFilePaths: [String] = cachedFilePaths[searchOptions] { + return cachedFilePaths + } + + guard let url = URL(string: path, relativeTo: FileManager.default.currentDirectoryUrl) else { + log.message("Could not convert path '\(path)' to type URL.", level: .error) + log.exit(fail: true) + } + + let propKeys = [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey] + guard + let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: propKeys, + options: [], + errorHandler: nil + ) + else { + log.message("Couldn't create enumerator for path '\(path)'.", level: .error) + log.exit(fail: true) + } + + 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(fail: true) + } + + // 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 os(Linux) + if isHiddenFilePath || fileUrl.path.contains("/.") || fileUrl.path.starts(with: ".") { + if !isRegularFilePath { + enumerator.skipDescendants() + } + + continue + } + #else + if isHiddenFilePath { + if !isRegularFilePath { + enumerator.skipDescendants() + } + + continue + } + #endif + + guard isRegularFilePath, includeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) else { + continue + } + + filePaths.append(fileUrl.relativePathFromCurrent) + } + + cachedFilePaths[searchOptions] = filePaths + return filePaths + } +} diff --git a/Sources/Checkers/Lint.swift b/Sources/Checkers/Lint.swift new file mode 100644 index 0000000..58206d4 --- /dev/null +++ b/Sources/Checkers/Lint.swift @@ -0,0 +1,271 @@ +import Foundation +import Core +import OrderedCollections +import ShellOut +import Reporting + +/// The linter type providing APIs for checking anything using regular expressions. +public enum Lint { + /// Checks the contents of files. + /// + /// - Parameters: + /// - check: 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. + /// - repeatIfAutoCorrected: Repeat check if at least one auto-correction was applied in last run. Defaults to `false`. + public static func checkFileContents( + check: Check, + regex: Regex, + matchingExamples: [String] = [], + nonMatchingExamples: [String] = [], + includeFilters: [Regex] = [try! Regex(#".*"#)], + excludeFilters: [Regex] = [], + autoCorrectReplacement: String? = nil, + autoCorrectExamples: [AutoCorrection] = [], + repeatIfAutoCorrected: Bool = false + ) throws -> [Violation] { + validate(regex: regex, matchesForEach: matchingExamples, check: check) + validate(regex: regex, doesNotMatchAny: nonMatchingExamples, check: check) + + validateParameterCombinations( + check: check, + autoCorrectReplacement: autoCorrectReplacement, + autoCorrectExamples: autoCorrectExamples, + violateIfNoMatchesFound: nil + ) + + if let autoCorrectReplacement = autoCorrectReplacement { + validateAutocorrectsAllExamples( + check: check, + examples: autoCorrectExamples, + regex: regex, + autocorrectReplacement: autoCorrectReplacement + ) + } + + let filePathsToCheck: [String] = FilesSearch.shared.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: includeFilters, + excludeFilters: excludeFilters + ) + + let violations = try FileContentsChecker( + id: check.id, + hint: check.hint, + severity: check.severity, + regex: regex, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: autoCorrectReplacement, + repeatIfAutoCorrected: repeatIfAutoCorrected + ) + .performCheck() + + return violations + } + + /// Checks the names of files. + /// + /// - Parameters: + /// - check: 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( + check: Check, + regex: Regex, + matchingExamples: [String] = [], + nonMatchingExamples: [String] = [], + includeFilters: [Regex] = [try! Regex(#".*"#)], + excludeFilters: [Regex] = [], + autoCorrectReplacement: String? = nil, + autoCorrectExamples: [AutoCorrection] = [], + violateIfNoMatchesFound: Bool = false + ) throws -> [Violation] { + validate(regex: regex, matchesForEach: matchingExamples, check: check) + validate(regex: regex, doesNotMatchAny: nonMatchingExamples, check: check) + validateParameterCombinations( + check: check, + autoCorrectReplacement: autoCorrectReplacement, + autoCorrectExamples: autoCorrectExamples, + violateIfNoMatchesFound: violateIfNoMatchesFound + ) + + if let autoCorrectReplacement = autoCorrectReplacement { + validateAutocorrectsAllExamples( + check: check, + examples: autoCorrectExamples, + regex: regex, + autocorrectReplacement: autoCorrectReplacement + ) + } + + let filePathsToCheck: [String] = FilesSearch.shared.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: includeFilters, + excludeFilters: excludeFilters + ) + + let violations = try FilePathsChecker( + id: check.id, + hint: check.hint, + severity: check.severity, + regex: regex, + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: autoCorrectReplacement, + violateIfNoMatchesFound: violateIfNoMatchesFound + ) + .performCheck() + + return violations + } + + /// Run custom scripts as checks. + /// + /// - Returns: If the command produces an output in the ``LintResults`` JSON format, will forward them. + /// If the output iis an array of ``Violation`` instances, they will be wrapped in a ``LintResults`` object. + /// Else, it will report exactly one violation if the command has a non-zero exit code with the last line(s) of output. + public static func runCustomScript(check: Check, command: String) throws -> LintResults { + let tempScriptFileUrl = URL(fileURLWithPath: "_\(check.id).tempscript") + try command.write(to: tempScriptFileUrl, atomically: true, encoding: .utf8) + try shellOut(to: "chmod", arguments: ["+x", tempScriptFileUrl.path]) + + do { + let output = try shellOut(to: "/bin/bash", arguments: [tempScriptFileUrl.path]) + + // clean up temporary script file after successful execution + try FileManager.default.removeItem(at: tempScriptFileUrl) + + if let jsonString = output.lintResultsJsonString, + let jsonData = jsonString.data(using: .utf8), + let lintResults: LintResults = try? JSONDecoder.iso.decode(LintResults.self, from: jsonData) + { + return lintResults + } + else if let jsonString = output.violationsArrayJsonString, + let jsonData = jsonString.data(using: .utf8), + let violations: [Violation] = try? JSONDecoder.iso.decode([Violation].self, from: jsonData) + { + return [check.severity: [check: violations]] + } + else { + // if the command fails, a ShellOutError will be thrown – here, none is thrown, so no violations + return [check.severity: [check: []]] + } + } + catch { + // clean up temporary script file after failed execution + try? FileManager.default.removeItem(at: tempScriptFileUrl) + + if let shellOutError = error as? ShellOutError, shellOutError.terminationStatus != 0 { + return [ + check.severity: [ + check: [ + Violation(message: shellOutError.output.components(separatedBy: .newlines).last) + ] + ] + ] + } + + throw error + } + } + + static func validate(regex: Regex, matchesForEach matchingExamples: [String], check: Check) { + for example in matchingExamples { + if !regex.matches(example) { + log.message( + "Couldn't find a match for regex \(regex) in check '\(check.id)' within matching example:\n\(example)", + level: .error + ) + log.exit(fail: true) + } + } + } + + static func validate(regex: Regex, doesNotMatchAny nonMatchingExamples: [String], check: Check) { + for example in nonMatchingExamples { + if regex.matches(example) { + log.message( + "Unexpectedly found a match for regex \(regex) in check '\(check.id)' within non-matching example:\n\(example)", + level: .error + ) + log.exit(fail: true) + } + } + } + + static func validateAutocorrectsAllExamples( + check: Check, + 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 \(check.id) did not result in expected output. + Before: '\(autocorrect.before.showWhitespacesAndNewlines())' + After: '\(autocorrected.showWhitespacesAndNewlines())' + Expected: '\(autocorrect.after.showWhitespacesAndNewlines())' + """, + level: .error + ) + log.exit(fail: true) + } + } + } + + static func validateParameterCombinations( + check: Check, + autoCorrectReplacement: String?, + autoCorrectExamples: [AutoCorrection], + violateIfNoMatchesFound: Bool? + ) { + if autoCorrectExamples.isFilled && autoCorrectReplacement == nil { + log.message( + "`autoCorrectExamples` provided for check \(check.id) without specifying an `autoCorrectReplacement`.", + level: .warning + ) + } + + guard autoCorrectReplacement == nil || violateIfNoMatchesFound != true else { + log.message( + "Incompatible options specified for check \(check.id): `autoCorrectReplacement` and `violateIfNoMatchesFound` can't be used together.", + level: .error + ) + log.exit(fail: true) + } + } +} + +fileprivate extension String { + var lintResultsJsonString: String? { + try! Regex( + #"\{.*(?:\"error\"\s*:|\"warning\"\s*:|\"info\"\s*:).*\}"#, + options: .dotMatchesLineSeparators + ) + .firstMatch(in: self)? + .string + } + + var violationsArrayJsonString: String? { + try! Regex( + #"\[(?:\s*\{.*\}\s*,)*(?:\s*\{.*\}\s*)?\]"#, + options: .dotMatchesLineSeparators + ) + .firstMatch(in: self)? + .string + } +} diff --git a/Sources/Commands/AnyLint.swift b/Sources/Commands/AnyLint.swift new file mode 100644 index 0000000..18059c1 --- /dev/null +++ b/Sources/Commands/AnyLint.swift @@ -0,0 +1,19 @@ +import Foundation +import ArgumentParser + +/// The entry point of the toot, defines the `anylint` primary command. Sets up any sub commands like `init` or `lint`. +struct AnyLint: ParsableCommand { + static var configuration: CommandConfiguration = .init( + commandName: "anylint", + abstract: "Lint anything by combining the power of scripts & regular expressions.", + discussion: """ + Configure regex or script based rules in AnyLint expected YAML configuration format. + + AnyLint supports `FileContents` and `FilePaths` checks based on regexes with autocorrection & test support. + Additionally, you can use `CustomScripts` to specify your own commands or scripts, e.g. other linters. + """, + version: "1.0.0", + subcommands: [LintCommand.self, InitCommand.self], + defaultSubcommand: LintCommand.self + ) +} diff --git a/Sources/Commands/Compatibility/main.swift b/Sources/Commands/Compatibility/main.swift new file mode 100644 index 0000000..dba66a6 --- /dev/null +++ b/Sources/Commands/Compatibility/main.swift @@ -0,0 +1,5 @@ +import ArgumentParser + +// TODO: [cg_2021-08-15] remove in favor of `@main` in AnyLint struct once GitHub has macos-11 available: +// https://github.com/actions/virtual-environments/issues/2486 +AnyLint.main() diff --git a/Sources/Commands/InitCommand.swift b/Sources/Commands/InitCommand.swift new file mode 100644 index 0000000..5c2ce09 --- /dev/null +++ b/Sources/Commands/InitCommand.swift @@ -0,0 +1,53 @@ +import Foundation +import ArgumentParser +import Configuration +import Core +import ShellOut + +/// The `init` subcommand helping to get started with AnyLint by setting up a configuration file from a template. +struct InitCommand: ParsableCommand { + static var configuration: CommandConfiguration = .init( + commandName: "init", + abstract: "Initializes a new AnyLint configuration file (at specified path & using the specified template)." + ) + + /// The template option to create the initial config file from. + @Option( + name: .shortAndLong, + help: "The template to create the initial config file from. One of: \(Template.optionsDescription)." + ) + var template: Template = .blank + + /// Path option to the new config file to initialize it at. + @Option( + name: .shortAndLong, + help: "Path to the new config file to initialize it at. If a directory is specified, creates 'anylint.yml' in it." + ) + var path: String = FileManager.default.currentDirectoryUrl.appendingPathComponent("anylint.yml").path + + mutating func run() throws { + // if the specified path is a directory, assume the user wants the default file name + if FileManager.default.fileExistsAndIsDirectory(atPath: path) { + path = path.appendingPathComponent("anylint.yml") + } + + guard !FileManager.default.fileExists(atPath: path) else { + log.message("Configuration file already exists at path '\(path)'.", level: .error) + log.exit(fail: true) + } + + log.message("Making sure config file directory exists ...", level: .info) + try shellOut(to: "mkdir", arguments: ["-p", path.parentDirectoryPath]) + + log.message("Creating config file using template '\(template.rawValue)' ...", level: .info) + FileManager.default.createFile( + atPath: path, + contents: template.fileContents, + attributes: nil + ) + + log.message("Successfully created config file at \(path)", level: .success) + } +} + +extension Template: ExpressibleByArgument {} diff --git a/Sources/Commands/LintCommand.swift b/Sources/Commands/LintCommand.swift new file mode 100644 index 0000000..0dd01a2 --- /dev/null +++ b/Sources/Commands/LintCommand.swift @@ -0,0 +1,139 @@ +import Foundation +import ArgumentParser +import Checkers +import Configuration +import Core +import Reporting +import Yams + +struct LintCommand: ParsableCommand { + static var configuration: CommandConfiguration = .init( + commandName: "lint", + abstract: "Runs the configured checks & reports the results in the specified format." + ) + + /// The path(s) option to run the checks from. + @Option( + name: .shortAndLong, + parsing: .upToNextOption, + help: .init("The path(s) to run the checks from.", valueName: "path") + ) + var paths: [String] = [FileManager.default.currentDirectoryUrl.path] + + /// Path option to the config file to execute. + @Option( + name: .shortAndLong, + help: .init("Path to the config file to execute.", valueName: "path") + ) + var config: String = FileManager.default.currentDirectoryUrl.appendingPathComponent("anylint.yml").path + + /// The minimum severity level option to fail on if any checks produce violations. + @Option( + name: .shortAndLong, + help: .init( + "The minimum severity level to fail on if any checks produce violations. One of: \(Severity.optionsDescription).", + valueName: "severity" + ) + ) + var failLevel: Severity = .error + + /// The expected format option of the output. + @Option( + name: .shortAndLong, + help: .init( + "The expected format of the output. One of: \(OutputFormat.optionsDescription).", + valueName: "format" + ) + ) + var outputFormat: OutputFormat = .commandLine + + mutating func run() throws { + if outputFormat == .xcode { + log = Logger.xcode + } + + guard FileManager.default.fileExists(atPath: config) else { + log.message( + "No configuration file found at \(config) – consider running `anylint init` with a template.", + level: .error + ) + log.exit(fail: true) + } + + let configFileUrl = URL(fileURLWithPath: config) + let configFileData = try Data(contentsOf: configFileUrl) + let lintConfig: LintConfiguration = try YAMLDecoder().decode(from: configFileData) + + log.message("Start linting using config file at \(config) ...", level: .info) + var lintResults: LintResults = [.info: [:], .warning: [:], .error: [:]] + + // run `FileContents` checks + for fileContentsConfig in lintConfig.fileContents { + let violations = try Lint.checkFileContents( + check: fileContentsConfig.check, + regex: fileContentsConfig.regex, + matchingExamples: fileContentsConfig.matchingExamples, + nonMatchingExamples: fileContentsConfig.nonMatchingExamples, + includeFilters: fileContentsConfig.includeFilters, + excludeFilters: fileContentsConfig.excludeFilters, + autoCorrectReplacement: fileContentsConfig.autoCorrectReplacement, + autoCorrectExamples: fileContentsConfig.autoCorrectExamples, + repeatIfAutoCorrected: fileContentsConfig.repeatIfAutoCorrected + ) + + lintResults.appendViolations(violations, forCheck: fileContentsConfig.check) + } + + // run `FilePaths` checks + for filePathsConfig in lintConfig.filePaths { + let violations = try Lint.checkFilePaths( + check: filePathsConfig.check, + regex: filePathsConfig.regex, + matchingExamples: filePathsConfig.matchingExamples, + nonMatchingExamples: filePathsConfig.nonMatchingExamples, + includeFilters: filePathsConfig.includeFilters, + excludeFilters: filePathsConfig.excludeFilters, + autoCorrectReplacement: filePathsConfig.autoCorrectReplacement, + autoCorrectExamples: filePathsConfig.autoCorrectExamples, + violateIfNoMatchesFound: filePathsConfig.violateIfNoMatchesFound + ) + + lintResults.appendViolations(violations, forCheck: filePathsConfig.check) + } + + // run `CustomScripts` checks + for customScriptConfig in lintConfig.customScripts { + let customScriptLintResults = try Lint.runCustomScript( + check: customScriptConfig.check, + command: customScriptConfig.command + ) + + lintResults.mergeResults(customScriptLintResults) + } + + // report violations & exit with right status code + lintResults.report(outputFormat: outputFormat) + + if lintResults.violations(severity: .error, excludeAutocorrected: outputFormat == .xcode).isFilled { + log.exit(fail: true) + } + else if failLevel == .warning, + lintResults.violations(severity: .warning, excludeAutocorrected: outputFormat == .xcode).isFilled + { + log.exit(fail: true) + } + else { + log.message("Linting successful using config file at \(config). Congrats! 🎉", level: .success) + log.exit(fail: false) + } + } +} + +extension Severity: ExpressibleByArgument {} +extension OutputFormat: ExpressibleByArgument {} + +extension CheckConfiguration { + var check: Check { + .init(id: id, hint: hint, severity: severity) + } +} diff --git a/Sources/Commands/OptionsStringConvertible.swift b/Sources/Commands/OptionsStringConvertible.swift new file mode 100644 index 0000000..562520a --- /dev/null +++ b/Sources/Commands/OptionsStringConvertible.swift @@ -0,0 +1,28 @@ +import Foundation +import Core +import Configuration +import Reporting + +/// A protocol to output a set of configuration options when asking for help. +protocol OptionsStringConvertible { + /// A human readable string representation of the possible options for help text. + static var optionsDescription: String { get } +} + +extension Template: OptionsStringConvertible { + static var optionsDescription: String { + allCases.map(\.rawValue).joined(separator: ", ") + } +} + +extension Severity: OptionsStringConvertible { + static var optionsDescription: String { + allCases.map(\.rawValue).joined(separator: ", ") + } +} + +extension OutputFormat: OptionsStringConvertible { + static var optionsDescription: String { + allCases.map(\.rawValue).joined(separator: ", ") + } +} diff --git a/Sources/Configuration/CheckConfiguration.swift b/Sources/Configuration/CheckConfiguration.swift new file mode 100644 index 0000000..fd76e30 --- /dev/null +++ b/Sources/Configuration/CheckConfiguration.swift @@ -0,0 +1,9 @@ +import Foundation +import Core + +/// Defines fields each check configuration needs to have. +public protocol CheckConfiguration { + var id: String { get } + var hint: String { get } + var severity: Severity { get } +} diff --git a/Sources/Configuration/Core+DefaultCodableStrategy.swift b/Sources/Configuration/Core+DefaultCodableStrategy.swift new file mode 100644 index 0000000..aea7e58 --- /dev/null +++ b/Sources/Configuration/Core+DefaultCodableStrategy.swift @@ -0,0 +1,17 @@ +import Foundation +import Core +import BetterCodable + +extension Severity { + /// Use to set the default value of `Severity` instances to `error` in rules when users don't provide an explicit value. + public enum DefaultToError: DefaultCodableStrategy { + public static var defaultValue: Severity { .error } + } +} + +extension Regex { + /// Use to set the default value of `Regex` instances to `.*` in rules when users don't provide an explicit value. + public enum DefaultToMatchAllArray: DefaultCodableStrategy { + public static var defaultValue: [Regex] { [try! Regex(".*")] } + } +} diff --git a/Sources/Configuration/CustomScriptsConfiguration.swift b/Sources/Configuration/CustomScriptsConfiguration.swift new file mode 100644 index 0000000..cf5c86d --- /dev/null +++ b/Sources/Configuration/CustomScriptsConfiguration.swift @@ -0,0 +1,21 @@ +import Foundation +import Core +import BetterCodable + +/// The `CustomScripts` check configuration type. +public struct CustomScriptsConfiguration: CheckConfiguration, Codable { + /// A unique identifier for the check to show on violations. + public let id: String + + /// A hint that should be shown on violations of this check. Should explain what's wrong and guide on fixing the issue. + public let hint: String + + /// The severity level of this check. One of `.info`, `.warning` or `.error`. Defaults to `.error`. + @DefaultCodable + public var severity: Severity + + /// The custom command line command to execute. + /// If the output conforms to the ``LintResults`` structure formatted as JSON, then the results will be merged. + /// Otherwise AnyLint will violate for any non-zero exit code with the last printed line. + public let command: String +} diff --git a/Sources/Configuration/FileContentsConfiguration.swift b/Sources/Configuration/FileContentsConfiguration.swift new file mode 100644 index 0000000..1ce8339 --- /dev/null +++ b/Sources/Configuration/FileContentsConfiguration.swift @@ -0,0 +1,69 @@ +import Foundation +import Core +import BetterCodable + +/// The `FileContents` check configuration type. +public struct FileContentsConfiguration: CheckConfiguration, Codable { + /// A unique identifier for the check to show on violations. Required. + public let id: String + + /// A hint that should be shown on violations of this check. Should explain what's wrong and guide on fixing the issue. Required. + public let hint: String + + /// The severity level of this check. One of `.info`, `.warning` or `.error`. Defaults to `.error`. + @DefaultCodable + public var severity: Severity + + /// The regular expression to use to find violations. Required. + public let regex: Regex + + /// A list of strings that are expected to match the provided `regex`. Optional. + /// + /// If any of the provided examples doesn't match, linting will fail early to ensure the provided `regex` works as expected. The check itself will not be run. + /// This can be considered a 'unit test' for the regex. It's recommended to provide at least one matching example & one non-matching example. + @DefaultEmptyArray + public var matchingExamples: [String] + + /// A list of strings that are expected to **not** to match the provided `regex`. Optional. + /// + /// If any of the provided examples matches, linting will fail early to ensure the provided `regex` works as expected. The check itself will not be run. + /// This can be considered a 'unit test' for the regex. It's recommended to provide at least one matching example & one non-matching example. + @DefaultEmptyArray + public var nonMatchingExamples: [String] + + /// A list of path-matching regexes to restrict this check to files in the matching paths only ("allow-listing"). Optional. + /// + /// When combined with `excludeFilters`, the exclude paths take precedence over the include paths – in other words: 'exclude always wins'. + @DefaultCodable + public var includeFilters: [Regex] + + /// A list of path-matching regexes to skip this check on for files with matching paths ("deny-listing"). Optional. + /// + /// When combined with `includeFilters`, the exclude paths take precedence over the include paths – in other words: 'exclude always wins' + @DefaultEmptyArray + public var excludeFilters: [Regex] + + /// A regex replacement template with `$1`-kind of references to capture groups in the regex. Optional. + /// + /// See `NSRegularExpression` documentation for examples and more details (e.g. in the 'Template Matching Format' section): + /// https://developer.apple.com/documentation/foundation/nsregularexpression + public let autoCorrectReplacement: String? + + /// A dictionary consisting of the keys `before` and `after` to specify how you would expect a given string to be changed. Optional. + /// + /// Use this to validate that the provided `regex` and the `autoCorrectReplacement` together act as expected. + /// If any of the provided `before` doesn't get transformed to the `after`, linting will fail early and the check itself will not be run. + /// + /// This can be considered a 'unit test' for the auto-correction. It's recommended to provide at least one pair if you specify use `autoCorrectReplacement`. + @DefaultEmptyArray + public var autoCorrectExamples: [AutoCorrection] + + /// If set to `true`, a check will be re-run if there was at least one auto-correction applied on the last run. Optional. + /// + /// This can be useful for auto-correcting issues that can scale or repeat. + /// For example, to ensure long numbers are separated by an underscore, you could write the regex `(\d+)(\d{3})` + /// and specify the replacement `$1_$2$3`. By default, the number `123456789` would be transformed to `123456_789`. + /// With this option set to `true`, the check would be re-executed after the first run (because there was a correction) and the result would be `123_456_789`. + @DefaultFalse + public var repeatIfAutoCorrected: Bool +} diff --git a/Sources/Configuration/FilePathsConfiguration.swift b/Sources/Configuration/FilePathsConfiguration.swift new file mode 100644 index 0000000..dda3b25 --- /dev/null +++ b/Sources/Configuration/FilePathsConfiguration.swift @@ -0,0 +1,66 @@ +import Foundation +import Core +import BetterCodable + +/// The `FilePaths` check configuration type. +public struct FilePathsConfiguration: CheckConfiguration, Codable { + /// A unique identifier for the check to show on violations. + public let id: String + + /// A hint that should be shown on violations of this check. Should explain what's wrong and guide on fixing the issue. + public let hint: String + + /// The severity level of this check. One of `.info`, `.warning` or `.error`. Defaults to `.error`. + @DefaultCodable + public var severity: Severity + + /// The regular expression to use to find violations. Required. + public let regex: Regex + + /// A list of strings that are expected to match the provided `regex`. Optional. + /// + /// If any of the provided examples doesn't match, linting will fail early to ensure the provided `regex` works as expected. The check itself will not be run. + /// This can be considered a 'unit test' for the regex. It's recommended to provide at least one matching example & one non-matching example. + @DefaultEmptyArray + public var matchingExamples: [String] + + /// A list of strings that are expected to **not** to match the provided `regex`. Optional. + /// + /// If any of the provided examples matches, linting will fail early to ensure the provided `regex` works as expected. The check itself will not be run. + /// This can be considered a 'unit test' for the regex. It's recommended to provide at least one matching example & one non-matching example. + @DefaultEmptyArray + public var nonMatchingExamples: [String] + + /// A list of path-matching regexes to restrict this check to files in the matching paths only ("allow-listing"). Optional. + /// + /// When combined with `excludeFilters`, the exclude paths take precedence over the include paths – in other words: 'exclude always wins'. + @DefaultCodable + public var includeFilters: [Regex] + + /// A list of path-matching regexes to skip this check on for files with matching paths ("deny-listing"). Optional. + /// + /// When combined with `includeFilters`, the exclude paths take precedence over the include paths – in other words: 'exclude always wins' + @DefaultEmptyArray + public var excludeFilters: [Regex] + + /// A regex replacement template with `$1`-kind of references to capture groups in the regex. Optional. + /// + /// See `NSRegularExpression` documentation for examples and more details (e.g. in the 'Template Matching Format' section): + /// https://developer.apple.com/documentation/foundation/nsregularexpression + /// + /// Use this to automatically move violating files from their current paht to the expected path. + public let autoCorrectReplacement: String? + + /// A dictionary consisting of the keys `before` and `after` to specify how you would expect a given path to be changed. Optional. + /// + /// Use this to validate that the provided `regex` and the `autoCorrectReplacement` together act as expected. + /// If any of the provided `before` doesn't get transformed to the `after`, linting will fail early and the check itself will not be run. + /// + /// This can be considered a 'unit test' for the auto-correction. It's recommended to provide at least one pair if you specify use `autoCorrectReplacement`. + @DefaultEmptyArray + public var autoCorrectExamples: [AutoCorrection] + + /// If set to `true`, a violation will be reported if **no** matches are found. By default (or if set to `false`), a check violates on every matching file path. + @DefaultFalse + public var violateIfNoMatchesFound: Bool +} diff --git a/Sources/Configuration/LintConfiguration.swift b/Sources/Configuration/LintConfiguration.swift new file mode 100644 index 0000000..9f3ae50 --- /dev/null +++ b/Sources/Configuration/LintConfiguration.swift @@ -0,0 +1,24 @@ +import Foundation +import Core +import BetterCodable + +/// The configuration file type. +public struct LintConfiguration: Codable { + enum CodingKeys: String, CodingKey { + case fileContents = "FileContents" + case filePaths = "FilePaths" + case customScripts = "CustomScripts" + } + + /// The list of `FileContents` checks. + @DefaultEmptyArray + public var fileContents: [FileContentsConfiguration] + + /// The list of `FilePaths` checks. + @DefaultEmptyArray + public var filePaths: [FilePathsConfiguration] + + /// The list of `CustomScripts` checks. + @DefaultEmptyArray + public var customScripts: [CustomScriptsConfiguration] +} diff --git a/Sources/Configuration/Template.swift b/Sources/Configuration/Template.swift new file mode 100644 index 0000000..b14e6ca --- /dev/null +++ b/Sources/Configuration/Template.swift @@ -0,0 +1,27 @@ +import Core +import Foundation + +/// The possible templates for setting up configuration initially. +public enum Template: String, CaseIterable { + /// The blank template with all existing checks and one 'Hello world' kind of example per check. + case blank + + /// The template with some useful checks setup for open source projects. + case openSource + + /// Returns the file contents for the chosen template. + public var fileContents: Data { + // NOTE: force unwrapping and force try safe together with `testFileContentsNotFailing` test & CI + let templateFileUrl = Bundle.module.url( + forResource: rawValue.firstUppercased, + withExtension: "yml", + subdirectory: "Templates" + )! + return try! Data(contentsOf: templateFileUrl) + } +} + +extension String { + /// Returns a variation with the first character uppercased. + fileprivate var firstUppercased: String { prefix(1).uppercased() + dropFirst() } +} diff --git a/Sources/Configuration/Templates/Blank.yml b/Sources/Configuration/Templates/Blank.yml new file mode 100644 index 0000000..8f5cab2 --- /dev/null +++ b/Sources/Configuration/Templates/Blank.yml @@ -0,0 +1,64 @@ +FileContents: [] +# TODO: replace below sample checks with your custom checks and remove empty array specifier `[]` from above + # - id: ReadmeTopLevelTitle + # hint: 'The README.md file should only contain a single top level title.' + # regex: '(^|\n)#[^#](.*\n)*\n#[^#]' + # includeFilters: ['^README\.md$'] + # matchingExamples: + # - | + # # Title + # ## Subtitle + # Lorem ipsum + # + # # Other Title + # ## Other Subtitle + # nonMatchingExamples: + # - | + # # Title + # ## Subtitle + # Lorem ipsum #1 and # 2. + # + # ## Other Subtitle + # ### Other Subsubtitle + # + # - id: ReadmeTypoLicense + # hint: 'ReadmeTypoLicense: Misspelled word `license`.' + # regex: '([\s#]L|l)isence([\s\.,:;])' + # matchingExamples: [' lisence:', "## Lisence\n"] + # nonMatchingExamples: [' license:', "## License\n"] + # includeFilters: ['^README\.md$'] + # autoCorrectReplacement: '$1icense$2' + # autoCorrectExamples: + # - { before: ' lisence:', after: ' license:' } + # - { before: "## Lisence\n", after: "## License\n" } + +FilePaths: [] +# TODO: replace below sample checks with your custom checks and remove empty array specifier `[]` from above + # - id: Readme + # hint: 'Each project should have a README.md file, explaining how to use or contribute to the project.' + # regex: '^README\.md$' + # violateIfNoMatchesFound: true + # matchingExamples: ['README.md'] + # nonMatchingExamples: ['README.markdown', 'Readme.md', 'ReadMe.md'] + # + # - id: ReadmePath + # hint: '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: + # - { before: 'api/readme.md', after: 'api/README.md' } + # - { before: 'ReadMe.md', after: 'README.md' } + # - { before: 'README.markdown', after: 'README.md' } + +CustomScripts: [] +# TODO: replace below sample check with your custom checks and remove empty array specifier `[]` from above + # - id: LintConfig + # hint: 'Lint the AnyLint config file to conform to YAML best practices.' + # command: |- + # if which yamllint > /dev/null; then + # yamllint anylint.yml + # else + # echo '{ "warning": { "YamlLint@warning: Not installed, see instructions at https://yamllint.readthedocs.io/en/stable/quickstart.html#installing-yamllint": [{}] } }' + # fi diff --git a/Sources/Configuration/Templates/OpenSource.yml b/Sources/Configuration/Templates/OpenSource.yml new file mode 100644 index 0000000..3fb7dfe --- /dev/null +++ b/Sources/Configuration/Templates/OpenSource.yml @@ -0,0 +1,61 @@ +FileContents: + - id: ReadmeTopLevelTitle + hint: 'The README.md file should only contain a single top level title.' + regex: '(^|\n)#[^#](.*\n)*\n#[^#]' + includeFilters: ['^README\.md$'] + matchingExamples: + - | + # Title + ## Subtitle + Lorem ipsum + + # Other Title + ## Other Subtitle + nonMatchingExamples: + - | + # Title + ## Subtitle + Lorem ipsum #1 and # 2. + + ## Other Subtitle + ### Other Subsubtitle + + - id: ReadmeTypoLicense + hint: 'ReadmeTypoLicense: Misspelled word `license`.' + regex: '([\s#]L|l)isence([\s\.,:;])' + matchingExamples: [' lisence:', "## Lisence\n"] + nonMatchingExamples: [' license:', "## License\n"] + includeFilters: ['^README\.md$'] + autoCorrectReplacement: '$1icense$2' + autoCorrectExamples: + - { before: ' lisence:', after: ' license:' } + - { before: "## Lisence\n", after: "## License\n" } + +FilePaths: + - id: Readme + hint: 'Each project should have a README.md file, explaining how to use or contribute to the project.' + regex: '^README\.md$' + violateIfNoMatchesFound: true + matchingExamples: ['README.md'] + nonMatchingExamples: ['README.markdown', 'Readme.md', 'ReadMe.md'] + + - id: ReadmePath + hint: '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: + - { before: 'api/readme.md', after: 'api/README.md' } + - { before: 'ReadMe.md', after: 'README.md' } + - { before: 'README.markdown', after: 'README.md' } + +CustomScripts: + - id: LintConfig + hint: 'Lint the AnyLint config file to conform to YAML best practices.' + command: |- + if which yamllint > /dev/null; then + yamllint anylint.yml + else + echo '{ "warning": { "YamlLint@warning: Not installed, see instructions at https://yamllint.readthedocs.io/en/stable/quickstart.html#installing-yamllint": [{}] } }' + fi diff --git a/Sources/Core/AutoCorrection.swift b/Sources/Core/AutoCorrection.swift new file mode 100644 index 0000000..70831ba --- /dev/null +++ b/Sources/Core/AutoCorrection.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Information about an autocorrection. +public struct AutoCorrection: Codable, Equatable { + private enum Constants { + /// The number of newlines required in both before and after of AutoCorrections required to use diff for outputs. + static let newlinesRequiredForDiffing: Int = 3 + } + + /// The matching text before applying the autocorrection. + public let before: String + + /// The matching text after applying the autocorrection. + public let after: String + + // TODO: [cg_2021-08-31] consider migrating over to https://github.com/pointfreeco/swift-custom-dump#diff + /// A summary of the applied autocorrections as human readable output. + public var appliedMessageLines: [String] { + if useDiffOutput, #available(OSX 10.15, *) { + var lines: [String] = ["Autocorrection applied, the diff is: (+ added, - removed)"] + + let beforeLines = before.components(separatedBy: .newlines) + let afterLines = after.components(separatedBy: .newlines) + + for difference in afterLines.difference(from: beforeLines).sorted() { + switch difference { + case let .insert(offset, element, _): + lines.append("+ [L\(offset + 1)] \(element)".coloredAsAdded) + + case let .remove(offset, element, _): + lines.append("- [L\(offset + 1)] \(element)".coloredAsRemoved) + } + } + + return lines + } + else { + return [ + "Autocorrection applied, the diff is: (+ added, - removed)", + "- \(before.showWhitespacesAndNewlines())".coloredAsRemoved, + "+ \(after.showWhitespacesAndNewlines())".coloredAsAdded, + ] + } + } + + var useDiffOutput: Bool { + before.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing + || after.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing + } + + /// Initializes an autocorrection. + public init( + before: String, + after: String + ) { + self.before = before + self.after = after + } +} + +// TODO: make the autocorrection diff sorted by line number +@available(OSX 10.15, *) +extension CollectionDifference.Change: Comparable where ChangeElement == String { + public static func < (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), + let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): + return leftOffset < rightOffset + + case let (.remove(leftOffset, _, _), .insert(rightOffset, _, _)): + return leftOffset < rightOffset || true + + case let (.insert(leftOffset, _, _), .remove(rightOffset, _, _)): + return leftOffset < rightOffset || false + } + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), + let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): + return leftOffset == rightOffset + + case (.remove, .insert), (.insert, .remove): + return false + } + } +} + +fileprivate extension String { + var coloredAsAdded: String { + #if DEBUG + return self // do not color when running tests + #else + return green + #endif + } + + var coloredAsRemoved: String { + #if DEBUG + return self // do not color when running tests + #else + return red + #endif + } +} diff --git a/Sources/Core/Check.swift b/Sources/Core/Check.swift new file mode 100644 index 0000000..000baa0 --- /dev/null +++ b/Sources/Core/Check.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Provides some basic information needed in each lint check. +public struct Check { + /// 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 Check: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +extension Check: Codable { + public init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + let rawString = try container.decode(String.self) + self.init(rawValue: rawString)! + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +extension Check: RawRepresentable { + public var rawValue: String { + "\(id)@\(severity.rawValue): \(hint)" + } + + public init?( + rawValue: String + ) { + let customSeverityRegex = try! Regex(#"^([^@:]+)@([^:]+): ?(.*)$"#) + + if let match = customSeverityRegex.firstMatch(in: rawValue) { + let id = match.captures[0]! + let severityString = match.captures[1]! + let hint = match.captures[2]! + + guard let severity = Severity(rawValue: severityString) else { + log.message( + "Specified severity '\(severityString)' for check '\(id)' unknown. Use one of [error, warning, info].", + level: .error + ) + log.exit(fail: true) + } + + self = .init(id: id, hint: hint, severity: severity) + } + else { + let defaultSeverityRegex = try! Regex(#"^([^@:]+): ?(.*$)"#) + + guard let defaultSeverityMatch = defaultSeverityRegex.firstMatch(in: rawValue) else { + log.message( + "Could not convert String literal '\(rawValue)' to type Check. Please check the structure to be: (@): ", + level: .error + ) + log.exit(fail: true) + } + + let id = defaultSeverityMatch.captures[0]! + let hint = defaultSeverityMatch.captures[1]! + + self = .init(id: id, hint: hint) + } + } +} + +extension Check: Comparable { + public static func < (lhs: Check, rhs: Check) -> Bool { + lhs.id < rhs.id + } +} diff --git a/Sources/Core/Extensions/CollectionExt.swift b/Sources/Core/Extensions/CollectionExt.swift new file mode 100644 index 0000000..4c16304 --- /dev/null +++ b/Sources/Core/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/Core/Extensions/Date+BetterCodable.swift b/Sources/Core/Extensions/Date+BetterCodable.swift new file mode 100644 index 0000000..6f9023d --- /dev/null +++ b/Sources/Core/Extensions/Date+BetterCodable.swift @@ -0,0 +1,28 @@ +import Foundation +import BetterCodable + +extension Date { + /// Use to set the default value of `Date` instances to `Date.now` in rules when users don't provide an explicit value. + public enum DefaultToNowISO8601: DateValueCodableStrategy { + public static func decode(_ value: String) throws -> Date { + (try? JSONDecoder.iso.decode(Date.self, from: value.data(using: .utf8)!)) ?? Date() + } + + public static func encode(_ date: Date) -> String { + String(data: try! JSONEncoder.iso.encode(date), encoding: .utf8)! + } + } +} + +// TODO: remove these once the related PR is merged: https://github.com/marksands/BetterCodable/pull/43 +extension DateValue: Equatable { + public static func == (lhs: DateValue, rhs: DateValue) -> Bool { + return lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DateValue: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} diff --git a/Sources/Core/Extensions/FileManagerExt.swift b/Sources/Core/Extensions/FileManagerExt.swift new file mode 100644 index 0000000..e9fdffb --- /dev/null +++ b/Sources/Core/Extensions/FileManagerExt.swift @@ -0,0 +1,14 @@ +import Foundation + +extension FileManager { + /// The current directory `URL`. + public var currentDirectoryUrl: URL { + URL(string: currentDirectoryPath)! + } + + /// 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/Core/Extensions/JSONDecoderExt.swift b/Sources/Core/Extensions/JSONDecoderExt.swift new file mode 100644 index 0000000..7e5bf7d --- /dev/null +++ b/Sources/Core/Extensions/JSONDecoderExt.swift @@ -0,0 +1,9 @@ +import Foundation + +extension JSONDecoder { + public static var iso: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Sources/Core/Extensions/JSONEncoderExt.swift b/Sources/Core/Extensions/JSONEncoderExt.swift new file mode 100644 index 0000000..1e54af8 --- /dev/null +++ b/Sources/Core/Extensions/JSONEncoderExt.swift @@ -0,0 +1,10 @@ +import Foundation + +extension JSONEncoder { + public static var iso: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + return encoder + } +} diff --git a/Sources/Core/Extensions/StringExt.swift b/Sources/Core/Extensions/StringExt.swift new file mode 100644 index 0000000..f9938ac --- /dev/null +++ b/Sources/Core/Extensions/StringExt.swift @@ -0,0 +1,79 @@ +import Foundation + +extension String { + /// Returns the location info for a given line index. + public func fileLocation(of index: String.Index, filePath: String) -> Location { + let prefix = self[startIndex.. String { + components(separatedBy: .newlines).joined(separator: #"\n"#) + } + + /// Returns a string that shows whitespaces as `␣`. + public func showWhitespaces() -> String { + components(separatedBy: .whitespaces).joined(separator: "␣") + } + + /// Returns a string that shows newlines as `\n` and whitespaces as `␣`. + public func showWhitespacesAndNewlines() -> String { + showNewlines().showWhitespaces() + } +} + +extension String { + /// The type of a given file path. + public enum PathType { + /// The relative path. + case relative + + /// The absolute path. + case absolute + } + + /// Returns the absolute path for a path given relative to the current directory. + public var absolutePath: String { + guard !self.starts(with: FileManager.default.currentDirectoryUrl.path) else { return self } + return FileManager.default.currentDirectoryUrl.appendingPathComponent(self).path + } + + /// Returns the relative path for a path given relative to the current directory. + public var relativePath: String { + guard self.starts(with: FileManager.default.currentDirectoryUrl.path) else { return self } + return replacingOccurrences(of: FileManager.default.currentDirectoryUrl.path, with: "") + } + + /// Returns the parent directory path. + public var parentDirectoryPath: String { + let url = URL(fileURLWithPath: self) + guard url.pathComponents.count > 1 else { return FileManager.default.currentDirectoryPath } + return url.deletingLastPathComponent().absoluteString + } + + /// Returns the path with the given type related to the current directory. + public func path(type: PathType) -> String { + switch type { + case .absolute: + return absolutePath + + case .relative: + return relativePath + } + } + + /// Returns the path with a components appended at it. + public func appendingPathComponent(_ pathComponent: String) -> String { + guard let pathUrl = URL(string: self) else { + log.message("Could not convert path '\(self)' to type URL.", level: .error) + log.exit(fail: true) + } + + return pathUrl.appendingPathComponent(pathComponent).absoluteString + } +} diff --git a/Sources/Core/Extensions/URLExt.swift b/Sources/Core/Extensions/URLExt.swift new file mode 100644 index 0000000..53e3f70 --- /dev/null +++ b/Sources/Core/Extensions/URLExt.swift @@ -0,0 +1,8 @@ +import Foundation + +extension URL { + /// Returns the relative path of from the current path. + public var relativePathFromCurrent: String { + String(path.replacingOccurrences(of: FileManager.default.currentDirectoryPath, with: "").dropFirst()) + } +} diff --git a/Sources/Core/Location.swift b/Sources/Core/Location.swift new file mode 100644 index 0000000..3e95546 --- /dev/null +++ b/Sources/Core/Location.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Info about the exact location of a character in a given file. +public struct Location: Codable, Equatable { + /// The path to the file. + public let filePath: String + + /// The row or line number of the location. + public let row: Int? + + /// The column or character index within a line of the location. + public let column: Int? + + /// Initializes a file location object. + public init( + filePath: String, + row: Int? = nil, + column: Int? = nil + ) { + self.filePath = filePath + self.row = row + self.column = column + } + + /// Returns a string representation of a violations filled with path and line information if available. + public func locationMessage(pathType: String.PathType) -> String { + if let row = row { + if let column = column { + return "\(filePath.path(type: pathType)):\(row):\(column):" + } + + return "\(filePath.path(type: pathType)):\(row):" + } + + return "\(filePath.path(type: pathType))" + } +} diff --git a/Sources/Core/Logging/ConsoleLogger.swift b/Sources/Core/Logging/ConsoleLogger.swift new file mode 100644 index 0000000..98206c9 --- /dev/null +++ b/Sources/Core/Logging/ConsoleLogger.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Helper to log output to console. +public final class ConsoleLogger: Loggable { + /// Communicates a message to console 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. + /// - location: The file, line and char in line location string. + public func message(_ message: String, level: PrintLevel, location: Location?) { + 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 dateTimeString = dateFormatter.string(from: Date()) + return "\(dateTimeString):" + } +} diff --git a/Sources/Core/Logging/Loggable.swift b/Sources/Core/Logging/Loggable.swift new file mode 100644 index 0000000..6baaf60 --- /dev/null +++ b/Sources/Core/Logging/Loggable.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Shortcut to access the `Logger` within this project. +public var log: Loggable = ConsoleLogger() + +public protocol Loggable { + func message(_ message: String, level: PrintLevel, location: Location?) +} + +extension Loggable { + /// Exits the current program with the given fail state. + public func exit(fail: Bool) -> Never { + let statusCode = fail ? EXIT_FAILURE : EXIT_SUCCESS + + #if os(Linux) + Glibc.exit(statusCode) + #else + Darwin.exit(statusCode) + #endif + } + + /// Convenience overload of `message(:level:fileLocation:)` with `fileLocation` set to `nil`. + public func message(_ message: String, level: PrintLevel) { + self.message(message, level: level, location: nil) + } +} diff --git a/Sources/Core/Logging/Logger.swift b/Sources/Core/Logging/Logger.swift new file mode 100644 index 0000000..105cb1a --- /dev/null +++ b/Sources/Core/Logging/Logger.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum Logger { + public static let console: ConsoleLogger = .init() + public static let xcode: XcodeLogger = .init() +} diff --git a/Sources/Core/Logging/PrintLevel.swift b/Sources/Core/Logging/PrintLevel.swift new file mode 100644 index 0000000..31d553f --- /dev/null +++ b/Sources/Core/Logging/PrintLevel.swift @@ -0,0 +1,33 @@ +import Foundation +import Rainbow + +/// 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 + } + } +} diff --git a/Sources/Core/Logging/XcodeLogger.swift b/Sources/Core/Logging/XcodeLogger.swift new file mode 100644 index 0000000..1d033c4 --- /dev/null +++ b/Sources/Core/Logging/XcodeLogger.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Helper to log output optimized for Xcode. +public final class XcodeLogger: Loggable { + /// Reports a message in an Xcode compatible format to be shown in the left pane. + /// + /// - Parameters: + /// - message: The message to be printed. Don't include `Error!`, `Warning!` or similar information at the beginning. + /// - level: The level of the print statement. + /// - location: The file, line and char in line location string. + public func message(_ message: String, level: PrintLevel, location: Location?) { + var locationPrefix = "" + + if let location = location { + locationPrefix = location.locationMessage(pathType: .absolute) + " " + } + + print("\(locationPrefix)\(level.rawValue): AnyLint: \(message)") + } +} diff --git a/Sources/Core/OutputFormat.swift b/Sources/Core/OutputFormat.swift new file mode 100644 index 0000000..3a8a4d0 --- /dev/null +++ b/Sources/Core/OutputFormat.swift @@ -0,0 +1,13 @@ +import Foundation + +/// The output format of violations and other statistics. +public enum OutputFormat: String, CaseIterable { + /// Output to the command line. Includes both violations & statistics summary at end. + case commandLine + + /// Output targeted to Xcode IDE. Includes only violations in the Xcode warning/error format. No statistics. + case xcode + + /// Output targeted to further usage from other tools or configurations. Output format same as script output, both violations & statistics. + case json +} diff --git a/Sources/Core/Regex.swift b/Sources/Core/Regex.swift new file mode 100644 index 0000000..dc01fa1 --- /dev/null +++ b/Sources/Core/Regex.swift @@ -0,0 +1,315 @@ +// 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 { + /// The recommended default options passed to any Regex if not otherwise specified. + public static let defaultOptions: Options = [.anchorsMatchLines] + + // MARK: - Properties + private let regularExpression: NSRegularExpression + + /// The regex patterns string. + public let pattern: String + + /// The regex options. + public let options: Options + + // 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 = defaultOptions + ) throws { + self.pattern = pattern + self.options = options + 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? { + regularExpression + .firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) + .map { Match(result: $0, in: string) } + } + + /// 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] { + regularExpression + .matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) + .map { Match(result: $0, in: string) } + } + + // 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.. 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(pattern) + hasher.combine(options) + } +} + +extension Regex: Decodable { + public init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + let pattern = try container.decode(String.self) + try self.init(pattern) + } +} + +extension Regex: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(pattern) + } +} + +// 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 + } + } +} + +extension Regex.Options: CustomStringConvertible { + public var description: String { + var description = "" + if contains(.ignoreCase) { description += "i" } + if contains(.ignoreMetacharacters) { description += "x" } + if !contains(.anchorsMatchLines) { description += "a" } + if contains(.dotMatchesLineSeparators) { description += "m" } + return description + } +} + +extension Regex.Options: Equatable, Hashable { + public static func == (lhs: Regex.Options, rhs: Regex.Options) -> Bool { + lhs.rawValue == rhs.rawValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(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 { + result.regularExpression! + .replacementString( + for: result, + in: baseString, + offset: 0, + template: template + ) + } + + // MARK: - CustomStringConvertible + /// Returns a string describing the match. + public var description: String { + #"Match<"\#(string)">"# + } + } +} diff --git a/Sources/Core/Severity.swift b/Sources/Core/Severity.swift new file mode 100644 index 0000000..360b587 --- /dev/null +++ b/Sources/Core/Severity.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Defines the severity of a lint check. +public enum Severity: String, CaseIterable, Codable { + /// 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 +} + +extension Severity: Comparable { + public static func < (lhs: Severity, rhs: Severity) -> Bool { + switch (lhs, rhs) { + case (.info, .warning), (.warning, .error), (.info, .error): + return true + + default: + return false + } + } +} + +extension Severity { + public var logLevel: PrintLevel { + switch self { + case .info: + return .info + + case .warning: + return .warning + + case .error: + return .error + } + } +} diff --git a/Sources/Core/Violation.swift b/Sources/Core/Violation.swift new file mode 100644 index 0000000..83cb8fd --- /dev/null +++ b/Sources/Core/Violation.swift @@ -0,0 +1,42 @@ +import Foundation +import BetterCodable + +/// A violation found in a check. +public struct Violation: Codable, Equatable { + /// The exact time this violation was discovered. Needed for sorting purposes. + @DateValue + public var discoverDate: Date + + /// 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 location: Location? + + /// The autocorrection applied to fix this violation. + public let appliedAutoCorrection: AutoCorrection? + + /// A custom violation message. + public let message: String? + + /// Initializes a violation object. + public init( + discoverDate: Date = Date(), + matchedString: String? = nil, + location: Location? = nil, + appliedAutoCorrection: AutoCorrection? = nil, + message: String? = nil + ) { + self.discoverDate = discoverDate + self.matchedString = matchedString + self.location = location + self.appliedAutoCorrection = appliedAutoCorrection + self.message = message + } +} + +extension Violation: Comparable { + public static func < (lhs: Violation, rhs: Violation) -> Bool { + lhs.discoverDate < rhs.discoverDate + } +} diff --git a/Sources/Reporting/LintResults.swift b/Sources/Reporting/LintResults.swift new file mode 100644 index 0000000..da42f2d --- /dev/null +++ b/Sources/Reporting/LintResults.swift @@ -0,0 +1,270 @@ +import Foundation +import Core +import OrderedCollections + +/// The linting output type. Can be merged from multiple instances into one. +public struct LintResults { + /// The checks and their validations accessible by severity level. + public var checkViolationsBySeverity: Dictionary> + + public init() { + self.checkViolationsBySeverity = [:] + } + + /// Returns a list of all executed checks. + public var allExecutedChecks: [Check] { + checkViolationsBySeverity.values.reduce(into: []) { $0.append(contentsOf: $1.keys) }.sorted() + } + + /// Returns a list of all found violations. + public var allFoundViolations: [Violation] { + checkViolationsBySeverity.values.reduce(into: []) { $0.append(contentsOf: $1.values.flatMap { $0 }) }.sorted() + } + + /// The highest severity with at least one violation. + func maxViolationSeverity(excludeAutocorrected: Bool) -> Severity? { + for severity in Severity.allCases.sorted().reversed() { + if let severityViolations = checkViolationsBySeverity[severity], + severityViolations.values.contains(where: { !$0.isEmpty }) + { + return severity + } + } + + return nil + } + + /// Merges the given lint results into this one. + public mutating func mergeResults(_ other: LintResults) { + checkViolationsBySeverity.merge(other.checkViolationsBySeverity) { currentDict, newDict in + currentDict.merging(newDict) { currentViolations, newViolations in + currentViolations + newViolations + } + } + } + + /// Appends the violations for the provided check to the results. + public mutating func appendViolations(_ violations: [Violation], forCheck check: Check) { + assert( + checkViolationsBySeverity.keys.contains(check.severity), + "Trying to add violations for severity \(check.severity) to LintResults without having initialized the severity key." + ) + + checkViolationsBySeverity[check.severity]![check] = violations + } + + /// Logs the summary of the violations in the specified output format. + public func report(outputFormat: OutputFormat) { + let executedChecks = allExecutedChecks + + if executedChecks.isEmpty { + log.message("No checks found to perform.", level: .warning) + } + else if checkViolationsBySeverity.values.contains(where: { $0.values.isFilled }) { + switch outputFormat { + case .commandLine: + reportToConsole() + + case .xcode: + reportToXcode() + + case .json: + reportToFile(at: "anylint-results.json") + } + } + else { + log.message( + "Performed \(executedChecks.count) check(s) without any violations.", + level: .success + ) + } + } + + /// Used to get validations for a specific severity level. + /// + /// - Parameters: + /// - severity: The severity to filter by. + /// - excludeAutocorrected: If `true`, autocorrected violations will not be returned, else returns all violations of the given severity level. + /// - Returns: The violations for a specific severity level. + public func violations(severity: Severity, excludeAutocorrected: Bool) -> [Violation] { + guard let violations = checkViolationsBySeverity[severity]?.values.flatMap({ $0 }) else { return [] } + guard excludeAutocorrected else { return violations } + return violations.filter { $0.appliedAutoCorrection == nil } + } + + /// Used to get validations for a specific check. + /// + /// - Parameters: + /// - check: The `Check` object to filter by. + /// - excludeAutocorrected: If `true`, autocorrected violations will not be returned, else returns all violations of the given severity level. + /// - Returns: The violations for a specific check. + public func violations(check: Check, excludeAutocorrected: Bool) -> [Violation] { + guard let violations: [Violation] = checkViolationsBySeverity[check.severity]?[check] else { return [] } + guard excludeAutocorrected else { return violations } + return violations.filter { $0.appliedAutoCorrection == nil } + } + + func reportToConsole() { + for check in allExecutedChecks { + let checkViolations = violations(check: check, excludeAutocorrected: false) + + if checkViolations.isFilled { + let violationsWithLocationMessage = checkViolations.filter { $0.location != 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.location!.locationMessage(pathType: .relative), + level: check.severity.logLevel + ) + + let prefixLengthWhitespaces = (0.. " + 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 = "\(violations(severity: .error, excludeAutocorrected: false).count) error(s)" + let warnings = "\(violations(severity: .warning, excludeAutocorrected: false).count) warning(s)" + + log.message( + "Performed \(allExecutedChecks.count) check(s) and found \(errors) & \(warnings).", + // TODO: [cg_2021-09-03] forward option "exclude autocorrected" to use here rather than using `false` + level: maxViolationSeverity(excludeAutocorrected: false)?.logLevel ?? .info + ) + } + + func reportToXcode() { + for severity in checkViolationsBySeverity.keys.sorted().reversed() { + guard let checkResultsAtSeverity = checkViolationsBySeverity[severity] else { continue } + + for (check, violations) in checkResultsAtSeverity { + for violation in violations where violation.appliedAutoCorrection == nil { + log.message( + "[\(check.id)] \(check.hint)", + level: severity.logLevel, + location: violation.location + ) + } + } + + } + } + + func reportToFile(at path: String) { + let resultFileUrl = URL(fileURLWithPath: path) + + do { + let resultsData = try JSONEncoder.iso.encode(self) + try resultsData.write(to: resultFileUrl) + + log.message("Successfully executed checks & reported results to file at \(resultFileUrl.path)", level: .info) + } + catch { + log.message("Failed to report results to file at \(resultFileUrl.path).", level: .error) + log.exit(fail: true) + } + } +} + +enum LintResultsDecodingError: Error { + case unknownSeverityRawValue(String) + case unknownCheckRawValue(String) +} + +/// Custom ``Codable`` implementation due to a Swift bug with custom key types: https://bugs.swift.org/browse/SR-7788 +extension LintResults: Codable { + public init( + from decoder: Decoder + ) throws { + let rawKeyedDictionary: [String: [String: [Violation]]] = try .init(from: decoder) + + self.checkViolationsBySeverity = [:] + + for (rawSeverity, checkRawValueViolationsDict) in rawKeyedDictionary { + guard let severity = Severity(rawValue: rawSeverity) else { + throw LintResultsDecodingError.unknownSeverityRawValue(rawSeverity) + } + + var checkViolationsDict: [Check: [Violation]] = .init() + + for (checkRawValue, violations) in checkRawValueViolationsDict { + guard let check = Check(rawValue: checkRawValue) else { + throw LintResultsDecodingError.unknownCheckRawValue(checkRawValue) + } + + checkViolationsDict[check] = violations + } + + self.checkViolationsBySeverity[severity] = checkViolationsDict + } + } + + public func encode(to encoder: Encoder) throws { + var rawKeyedOuterDict: Dictionary> = .init() + + for (severity, checkViolationsDict) in checkViolationsBySeverity { + var rawKeyedInnerDict: Dictionary = .init() + + for (check, violations) in checkViolationsDict { + rawKeyedInnerDict[check.rawValue] = violations + } + + rawKeyedOuterDict[severity.rawValue] = rawKeyedInnerDict + } + + var container = encoder.singleValueContainer() + try container.encode(rawKeyedOuterDict) + } +} + +extension LintResults: ExpressibleByDictionaryLiteral { + public init( + dictionaryLiteral elements: (Severity, Dictionary)... + ) { + var newDict: Dictionary> = .init() + + for (key, value) in elements { + newDict[key] = value + } + + self.checkViolationsBySeverity = newDict + } +} + +extension LintResults: Equatable {} diff --git a/Sources/TestSupport/Extensions/DateExt.swift b/Sources/TestSupport/Extensions/DateExt.swift new file mode 100644 index 0000000..7c080b1 --- /dev/null +++ b/Sources/TestSupport/Extensions/DateExt.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Date { + /// Returns a sample Date for testing purposes. Use the same seed to get the same date. + public static func sample(seed: Int) -> Date { + Date(timeIntervalSinceReferenceDate: Double(seed) * 60 * 60) + } +} diff --git a/Sources/TestSupport/TestLogger.swift b/Sources/TestSupport/TestLogger.swift new file mode 100644 index 0000000..92c92e6 --- /dev/null +++ b/Sources/TestSupport/TestLogger.swift @@ -0,0 +1,30 @@ +import Foundation +import Core +import Rainbow + +public final class TestLogger: Loggable { + public var loggedMessages: [String] + public var exitStatusCode: Int32? + + public init() { + loggedMessages = [] + } + + public func message(_ message: String, level: PrintLevel, location: Location?) { + if let location = location { + loggedMessages.append( + "[\(level.rawValue)] \(location.locationMessage(pathType: .relative)) \(message.clearColor.clearStyles)" + ) + } + else { + loggedMessages.append( + "[\(level.rawValue)] \(message.clearColor.clearStyles)" + ) + } + } + + public func exit(fail: Bool) -> Never { + exitStatusCode = fail ? EXIT_FAILURE : EXIT_SUCCESS + fatalError() + } +} diff --git a/Sources/TestSupport/XCTestCaseExt.swift b/Sources/TestSupport/XCTestCaseExt.swift new file mode 100644 index 0000000..892d9af --- /dev/null +++ b/Sources/TestSupport/XCTestCaseExt.swift @@ -0,0 +1,26 @@ +import Foundation +import XCTest + +extension XCTestCase { + public typealias TemporaryFile = (subpath: String, contents: String) + + public var tempDir: String { "AnyLintTempTests" } + + public 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/Sources/Utility/Constants.swift b/Sources/Utility/Constants.swift deleted file mode 100644 index df77c8b..0000000 --- a/Sources/Utility/Constants.swift +++ /dev/null @@ -1,37 +0,0 @@ -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.8.2" - - /// The name of this tool. - public static let toolName: String = "AnyLint" - - /// The debug mode argument for command line pass-through. - public static let debugArgument: String = "debug" - - /// The strict mode argument for command-line pass-through. - public static let strictArgument: String = "strict" - - /// The validate-only mode argument for command-line pass-through. - public static let validateArgument: String = "validate" - - /// The separator indicating that next come regex options. - public static let regexOptionsSeparator: String = #"\"# - - /// Hint that the case insensitive option should be active on a Regex. - public static let caseInsensitiveRegexOption: String = "i" - - /// Hint that the case dot matches newline option should be active on a Regex. - public static let dotMatchesNewlinesRegexOption: String = "m" - - /// The number of newlines required in both before and after of AutoCorrections required to use diff for outputs. - public static let newlinesRequiredForDiffing: Int = 3 -} diff --git a/Sources/Utility/Extensions/CollectionExt.swift b/Sources/Utility/Extensions/CollectionExt.swift deleted file mode 100644 index 7d9a099..0000000 --- a/Sources/Utility/Extensions/CollectionExt.swift +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 05e3701..0000000 --- a/Sources/Utility/Extensions/FileManagerExt.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -extension FileManager { - /// The current directory `URL`. - public var currentDirectoryUrl: URL { - URL(string: currentDirectoryPath)! - } - - /// 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 deleted file mode 100644 index 183abce..0000000 --- a/Sources/Utility/Extensions/RegexExt.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation - -extension Regex: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { - var pattern = value - let options: Options = { - if - value.hasSuffix(Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption + Constants.dotMatchesNewlinesRegexOption) - || value.hasSuffix(Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption + Constants.caseInsensitiveRegexOption) - { - pattern.removeLast((Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption + Constants.caseInsensitiveRegexOption).count) - return Regex.defaultOptions.union([.ignoreCase, .dotMatchesLineSeparators]) - } else if value.hasSuffix(Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption) { - pattern.removeLast((Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption).count) - return Regex.defaultOptions.union([.ignoreCase]) - } else if value.hasSuffix(Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption) { - pattern.removeLast((Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption).count) - return Regex.defaultOptions.union([.dotMatchesLineSeparators]) - } else { - return Regex.defaultOptions - } - }() - - do { - self = try Regex(pattern, options: options) - } 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)...) { - var patternElements = elements - var options: Options = Regex.defaultOptions - - if let regexOptionsValue = elements.last(where: { $0.0 == Constants.regexOptionsSeparator })?.1 { - patternElements.removeAll { $0.0 == Constants.regexOptionsSeparator } - - if regexOptionsValue.contains(Constants.caseInsensitiveRegexOption) { - options.insert(.ignoreCase) - } - - if regexOptionsValue.contains(Constants.dotMatchesNewlinesRegexOption) { - options.insert(.dotMatchesLineSeparators) - } - } - - do { - let pattern: String = patternElements.reduce(into: "") { result, element in result.append("(?<\(element.0)>\(element.1))") } - self = try Regex(pattern, options: options) - } 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 deleted file mode 100644 index ebbe3f9..0000000 --- a/Sources/Utility/Extensions/StringExt.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -extension String { - /// The type of a given file path. - public enum PathType { - /// The relative path. - case relative - - /// The absolute path. - case absolute - } - - /// Returns the absolute path for a path given relative to the current directory. - public var absolutePath: String { - guard !self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } - return fileManager.currentDirectoryUrl.appendingPathComponent(self).path - } - - /// Returns the relative path for a path given relative to the current directory. - public var relativePath: String { - guard self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } - return replacingOccurrences(of: fileManager.currentDirectoryUrl.path, with: "") - } - - /// Returns the parent directory path. - public var parentDirectoryPath: String { - let url = URL(fileURLWithPath: self) - guard url.pathComponents.count > 1 else { return fileManager.currentDirectoryPath } - return url.deletingLastPathComponent().absoluteString - } - - /// Returns the path with the given type related to the current directory. - public func path(type: PathType) -> String { - switch type { - case .absolute: - return absolutePath - - case .relative: - return relativePath - } - } - - /// Returns the path with a components appended at it. - public func appendingPathComponent(_ pathComponent: String) -> String { - guard let pathUrl = URL(string: self) else { - 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 deleted file mode 100644 index 260afc3..0000000 --- a/Sources/Utility/Logger.swift +++ /dev/null @@ -1,159 +0,0 @@ -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 - - /// Print detailed information for debugging purposes. - case debug - - 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 - - case .debug: - return Color.default - } - } - } - - /// The output type. - public enum OutputType: String { - /// Output is targeted to a console to be read by developers. - case console - - /// Output is targeted to Xcodes left pane to be interpreted by it to mark errors & warnings. - case xcode - - /// Output is targeted for unit tests. Collect into globally accessible TestHelper. - case test - } - - /// 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 - } - } - } - - /// The output type of the logger. - public let outputType: OutputType - - /// Defines if the log should include debug logs. - public var logDebugLevel: Bool = false - - /// Initializes a new Logger object with a given output type. - public 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) { - guard level != .debug || logDebugLevel else { return } - - switch outputType { - case .console: - consoleMessage(message, level: level) - - case .xcode: - xcodeMessage(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, .xcode: - #if os(Linux) - Glibc.exit(status.statusCode) - #else - Darwin.exit(status.statusCode) - #endif - - 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) - - case .debug: - print(formattedCurrentTime(), "💬", message) - } - } - - /// Reports a message in an Xcode compatible format to be shown in the left pane. - /// - /// - Parameters: - /// - message: The message to be printed. Don't include `Error!`, `Warning!` or similar information at the beginning. - /// - level: The level of the print statement. - /// - location: The file, line and char in line location string. - public func xcodeMessage(_ message: String, level: PrintLevel, location: String? = nil) { - if let location = location { - print("\(location) \(level.rawValue): \(Constants.toolName): \(message)") - } else { - print("\(level.rawValue): \(Constants.toolName): \(message)") - } - } - - private func formattedCurrentTime() -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss.SSS" - let dateTime = dateFormatter.string(from: Date()) - return "\(dateTime):" - } -} diff --git a/Sources/Utility/Regex.swift b/Sources/Utility/Regex.swift deleted file mode 100644 index f2231d1..0000000 --- a/Sources/Utility/Regex.swift +++ /dev/null @@ -1,292 +0,0 @@ -// 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 { - /// The recommended default options passed to any Regex if not otherwise specified. - public static let defaultOptions: Options = [.anchorsMatchLines] - - // MARK: - Properties - private let regularExpression: NSRegularExpression - - /// The regex patterns string. - public let pattern: String - - /// The regex options. - public let options: Options - - // 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 = defaultOptions) throws { - self.pattern = pattern - self.options = options - 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)/\(options)" - } -} - -// 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(pattern) - hasher.combine(options) - } -} - -// 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 - } - } -} - -extension Regex.Options: CustomStringConvertible { - public var description: String { - var description = "" - if contains(.ignoreCase) { description += "i" } - if contains(.ignoreMetacharacters) { description += "x" } - if !contains(.anchorsMatchLines) { description += "a" } - if contains(.dotMatchesLineSeparators) { description += "m" } - return description - } -} - -extension Regex.Options: Equatable, Hashable { - public static func == (lhs: Regex.Options, rhs: Regex.Options) -> Bool { - lhs.rawValue == rhs.rawValue - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(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 deleted file mode 100644 index 1b72080..0000000 --- a/Sources/Utility/TestHelper.swift +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 5be114c..0000000 --- a/Tests/AnyLintCLITests/AnyLintCLITests.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -final class AnyLintCLITests: XCTestCase { - func testExample() { - // TODO: [cg_2020-03-07] not yet implemented - } -} diff --git a/Tests/AnyLintTests/AutoCorrectionTests.swift b/Tests/AnyLintTests/AutoCorrectionTests.swift deleted file mode 100644 index f554ec5..0000000 --- a/Tests/AnyLintTests/AutoCorrectionTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -@testable import AnyLint -import XCTest - -final class AutoCorrectionTests: XCTestCase { - func testInitWithDictionaryLiteral() { - let autoCorrection: AutoCorrection = ["before": "Lisence", "after": "License"] - XCTAssertEqual(autoCorrection.before, "Lisence") - XCTAssertEqual(autoCorrection.after, "License") - } - - func testAppliedMessageLines() { - let singleLineAutoCorrection: AutoCorrection = ["before": "Lisence", "after": "License"] - XCTAssertEqual( - singleLineAutoCorrection.appliedMessageLines, - [ - "Autocorrection applied, the diff is: (+ added, - removed)", - "- Lisence", - "+ License", - ] - ) - - let multiLineAutoCorrection: AutoCorrection = [ - "before": "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\n", - "after": "A\nB\nD\nE\nF1\nF2\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\n", - ] - XCTAssertEqual( - multiLineAutoCorrection.appliedMessageLines, - [ - "Autocorrection applied, the diff is: (+ added, - removed)", - "- [L3] C", - "+ [L5] F1", - "- [L6] F", - "+ [L6] F2", - ] - ) - } -} diff --git a/Tests/AnyLintTests/CheckInfoTests.swift b/Tests/AnyLintTests/CheckInfoTests.swift deleted file mode 100644 index ad69b2e..0000000 --- a/Tests/AnyLintTests/CheckInfoTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -@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 deleted file mode 100644 index f1eefa7..0000000 --- a/Tests/AnyLintTests/Checkers/FileContentsCheckerTests.swift +++ /dev/null @@ -1,217 +0,0 @@ -@testable import AnyLint -@testable import Utility -import XCTest - -// swiftlint:disable function_body_length - -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, - repeatIfAutoCorrected: false - ).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) - } - } - - func testSkipInFile() { - let temporaryFiles: [TemporaryFile] = [ - (subpath: "Sources/Hello.swift", contents: "// AnyLint.skipInFile: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10"), - (subpath: "Sources/World.swift", contents: "// AnyLint.skipInFile: All\n\n\nlet x=5\nvar y=10"), - (subpath: "Sources/Foo.swift", contents: "// AnyLint.skipInFile: OtherRule\n\n\nlet 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, - repeatIfAutoCorrected: false - ).performCheck() - - XCTAssertEqual(violations.count, 2) - - XCTAssertEqual(violations[0].checkInfo, checkInfo) - XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/Foo.swift") - XCTAssertEqual(violations[0].locationInfo!.line, 4) - XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[1].checkInfo, checkInfo) - XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/Foo.swift") - XCTAssertEqual(violations[1].locationInfo!.line, 5) - XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) - } - } - - func testSkipHere() { - let temporaryFiles: [TemporaryFile] = [ - (subpath: "Sources/Hello.swift", contents: "// AnyLint.skipHere: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10"), - (subpath: "Sources/World.swift", contents: "\n\n// AnyLint.skipHere: OtherRule, Whitespacing\nlet x=5\nvar y=10"), - (subpath: "Sources/Foo.swift", contents: "\n\n\nlet x=5\nvar y=10 // AnyLint.skipHere: OtherRule, Whitespacing\n"), - (subpath: "Sources/Bar.swift", contents: "\n\n\nlet x=5\nvar y=10\n// AnyLint.skipHere: OtherRule, Whitespacing"), - ] - - 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, - repeatIfAutoCorrected: false - ).performCheck() - - XCTAssertEqual(violations.count, 6) - - XCTAssertEqual(violations[0].checkInfo, checkInfo) - XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/Hello.swift") - XCTAssertEqual(violations[0].locationInfo!.line, 4) - XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[1].checkInfo, checkInfo) - XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/Hello.swift") - XCTAssertEqual(violations[1].locationInfo!.line, 5) - XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[2].checkInfo, checkInfo) - XCTAssertEqual(violations[2].filePath, "\(tempDir)/Sources/World.swift") - XCTAssertEqual(violations[2].locationInfo!.line, 5) - XCTAssertEqual(violations[2].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[3].checkInfo, checkInfo) - XCTAssertEqual(violations[3].filePath, "\(tempDir)/Sources/Foo.swift") - XCTAssertEqual(violations[3].locationInfo!.line, 4) - XCTAssertEqual(violations[3].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[4].checkInfo, checkInfo) - XCTAssertEqual(violations[4].filePath, "\(tempDir)/Sources/Bar.swift") - XCTAssertEqual(violations[4].locationInfo!.line, 4) - XCTAssertEqual(violations[4].locationInfo!.charInLine, 1) - - XCTAssertEqual(violations[5].checkInfo, checkInfo) - XCTAssertEqual(violations[5].filePath, "\(tempDir)/Sources/Bar.swift") - XCTAssertEqual(violations[5].locationInfo!.line, 5) - XCTAssertEqual(violations[5].locationInfo!.charInLine, 1) - } - } - - func testSkipIfEqualsToAutocorrectReplacement() { - 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+)\s*=\s*(\w+)"#, - filePathsToCheck: filePathsToCheck, - autoCorrectReplacement: "$1 $2 = $3", - repeatIfAutoCorrected: false - ).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) - } - } - - func testRepeatIfAutoCorrected() { - let temporaryFiles: [TemporaryFile] = [ - (subpath: "Sources/Hello.swift", contents: "let x = 500\nvar y = 10000"), - (subpath: "Sources/World.swift", contents: "let x = 50000000\nvar y = 100000000000000"), - ] - - withTemporaryFiles(temporaryFiles) { filePathsToCheck in - let checkInfo = CheckInfo(id: "LongNumbers", hint: "Format long numbers with `_` after each triple of digits from the right.", severity: .warning) - let violations = try FileContentsChecker( - checkInfo: checkInfo, - regex: #"(? 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/ArrayExtTests.swift b/Tests/AnyLintTests/Extensions/ArrayExtTests.swift deleted file mode 100644 index 6162f5b..0000000 --- a/Tests/AnyLintTests/Extensions/ArrayExtTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -@testable import AnyLint -@testable import Utility -import XCTest - -final class ArrayExtTests: XCTestCase { - func testContainsLineAtIndexesMatchingRegex() { - let regex: Regex = #"foo:bar"# - let lines: [String] = ["hello\n foo", "hello\n foo bar", "hello bar", "\nfoo:\nbar", "foo:bar", ":foo:bar"] - - XCTAssertFalse(lines.containsLine(at: [1, 2, 3], matchingRegex: regex)) - XCTAssertFalse(lines.containsLine(at: [-2, -1, 0], matchingRegex: regex)) - XCTAssertFalse(lines.containsLine(at: [-1, 2, 10], matchingRegex: regex)) - XCTAssertFalse(lines.containsLine(at: [3, 2], matchingRegex: regex)) - - XCTAssertTrue(lines.containsLine(at: [-2, 3, 4], matchingRegex: regex)) - XCTAssertTrue(lines.containsLine(at: [5, 6, 7], matchingRegex: regex)) - XCTAssertTrue(lines.containsLine(at: [-2, 4, 10], matchingRegex: regex)) - } -} diff --git a/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift b/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift deleted file mode 100644 index a59d9d8..0000000 --- a/Tests/AnyLintTests/Extensions/XCTestCaseExt.swift +++ /dev/null @@ -1,25 +0,0 @@ -@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 deleted file mode 100644 index 5b78388..0000000 --- a/Tests/AnyLintTests/FilesSearchTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -@testable import AnyLint -@testable import Utility -import XCTest - -// swiftlint:disable force_try - -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.shared.allFiles( - within: FileManager.default.currentDirectoryPath, - includeFilters: [try Regex("\(tempDir)/.*")], - excludeFilters: [] - ).sorted() - XCTAssertEqual(includeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift", "\(tempDir)/Sources/World.swift"]) - - let excludeFilterFilePaths = FilesSearch.shared.allFiles( - within: FileManager.default.currentDirectoryPath, - includeFilters: [try Regex("\(tempDir)/.*")], - excludeFilters: ["World"] - ) - XCTAssertEqual(excludeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift"]) - } - } - - func testPerformanceOfSameSearchOptions() { - let swiftSourcesFilePaths = (0 ... 800).map { (subpath: "Sources/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } - let testsFilePaths = (0 ... 400).map { (subpath: "Tests/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } - let storyboardSourcesFilePaths = (0 ... 300).map { (subpath: "Sources/Foo\($0).storyboard", contents: "Lorem ipsum\ndolor sit amet\n") } - - withTemporaryFiles(swiftSourcesFilePaths + testsFilePaths + storyboardSourcesFilePaths) { _ in - let fileSearchCode: () -> [String] = { - FilesSearch.shared.allFiles( - within: FileManager.default.currentDirectoryPath, - includeFilters: [try! Regex(#"\#(self.tempDir)/Sources/Foo.*"#)], - excludeFilters: [try! Regex(#"\#(self.tempDir)/.*\.storyboard"#)] - ) - } - - // first run - XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) - - measure { - // subsequent runs (should be much faster) - XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) - XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) - } - } - } -} diff --git a/Tests/AnyLintTests/LintTests.swift b/Tests/AnyLintTests/LintTests.swift deleted file mode 100644 index 99402c6..0000000 --- a/Tests/AnyLintTests/LintTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -@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 deleted file mode 100644 index b729aa7..0000000 --- a/Tests/AnyLintTests/RegexExtTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -@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 deleted file mode 100644 index 6d63653..0000000 --- a/Tests/AnyLintTests/StatisticsTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -@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.logCheckSummary() - 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.checkedFiles(at: ["Hogwarts/Harry.swift"]) - Statistics.shared.checkedFiles(at: ["Hogwarts/Harry.swift", "Hogwarts/Albus.swift"]) - Statistics.shared.checkedFiles(at: ["Hogwarts/Albus.swift"]) - - Statistics.shared.logCheckSummary() - - 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) in 2 file(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 deleted file mode 100644 index c9d0ebd..0000000 --- a/Tests/AnyLintTests/ViolationTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -@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(pathType: .relative)) - - let fileViolation = Violation(checkInfo: checkInfo, filePath: "Temp/Souces/Hello.swift") - XCTAssertEqual(fileViolation.locationMessage(pathType: .relative), "Temp/Souces/Hello.swift") - - let locationInfoViolation = Violation( - checkInfo: checkInfo, - filePath: "Temp/Souces/World.swift", - locationInfo: String.LocationInfo(line: 5, charInLine: 15) - ) - - XCTAssertEqual(locationInfoViolation.locationMessage(pathType: .relative), "Temp/Souces/World.swift:5:15:") - } -} diff --git a/Tests/CheckersTests/Extensions/ArrayExtTests.swift b/Tests/CheckersTests/Extensions/ArrayExtTests.swift new file mode 100644 index 0000000..bb44cb4 --- /dev/null +++ b/Tests/CheckersTests/Extensions/ArrayExtTests.swift @@ -0,0 +1,19 @@ +@testable import Checkers +import Core +import XCTest + +final class ArrayExtTests: XCTestCase { + func testContainsLineAtIndexesMatchingRegex() throws { + let regex: Regex = try .init(#"foo:bar"#) + let lines: [String] = ["hello\n foo", "hello\n foo bar", "hello bar", "\nfoo:\nbar", "foo:bar", ":foo:bar"] + + XCTAssertFalse(lines.containsLine(at: [1, 2, 3], matchingRegex: regex)) + XCTAssertFalse(lines.containsLine(at: [-2, -1, 0], matchingRegex: regex)) + XCTAssertFalse(lines.containsLine(at: [-1, 2, 10], matchingRegex: regex)) + XCTAssertFalse(lines.containsLine(at: [3, 2], matchingRegex: regex)) + + XCTAssertTrue(lines.containsLine(at: [-2, 3, 4], matchingRegex: regex)) + XCTAssertTrue(lines.containsLine(at: [5, 6, 7], matchingRegex: regex)) + XCTAssertTrue(lines.containsLine(at: [-2, 4, 10], matchingRegex: regex)) + } +} diff --git a/Tests/CheckersTests/Extensions/RegexExtTests.swift b/Tests/CheckersTests/Extensions/RegexExtTests.swift new file mode 100644 index 0000000..6dfaa71 --- /dev/null +++ b/Tests/CheckersTests/Extensions/RegexExtTests.swift @@ -0,0 +1,28 @@ +import Core +import XCTest + +final class RegexExtTests: XCTestCase { + func testReplacingMatchesInInputWithTemplate() throws { + let regexTrailing: Regex = try .init(#"(?<=\n)([-–] .*[^ ])( {0,1}| {3,})\n"#) + let text: String = "\n- Sample Text.\n" + + XCTAssertEqual( + regexTrailing.replacingMatches(in: text, with: "$1 \n"), + "\n- Sample Text. \n" + ) + } + + func testReplaceAllCaptures() throws { + let anonymousRefsRegex = try Regex(#"(\w+)\.(\w+)\.(\w+)"#) + XCTAssertEqual( + anonymousRefsRegex.replaceAllCaptures(in: "prefix.content.suffix", with: "$3-$2-$1"), + "suffix-content-prefix" + ) + + let namedRefsRegex = try Regex(#"(?\w+)\.(?\w+)\.(?\w+)"#) + XCTAssertEqual( + namedRefsRegex.replaceAllCaptures(in: "prefix.content.suffix", with: "$suffix-$content-$prefix"), + "suffix-content-prefix" + ) + } +} diff --git a/Tests/CheckersTests/FileContentsCheckerTests.swift b/Tests/CheckersTests/FileContentsCheckerTests.swift new file mode 100644 index 0000000..b2561de --- /dev/null +++ b/Tests/CheckersTests/FileContentsCheckerTests.swift @@ -0,0 +1,226 @@ +@testable import Checkers +import XCTest +import Core +import TestSupport + +final class FileContentsCheckerTests: XCTestCase { + 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 violations = try FileContentsChecker( + id: "Whitespacing", + hint: "Always add a single whitespace around '='.", + severity: .warning, + regex: Regex(#"(let|var) \w+=\w+"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + repeatIfAutoCorrected: false + ) + .performCheck() + + XCTAssertEqual(violations.count, 2) + + XCTAssertEqual(violations[0].matchedString, "let x=5") + XCTAssertEqual(violations[0].location?.filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[0].location?.row, 1) + XCTAssertEqual(violations[0].location!.column, 1) + + XCTAssertEqual(violations[1].matchedString, "var y=10") + XCTAssertEqual(violations[1].location?.filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[1].location?.row, 2) + XCTAssertEqual(violations[1].location?.column, 1) + } + } + + func testSkipInFile() { + let temporaryFiles: [TemporaryFile] = [ + ( + subpath: "Sources/Hello.swift", + contents: "// AnyLint.skipInFile: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10" + ), + (subpath: "Sources/World.swift", contents: "// AnyLint.skipInFile: All\n\n\nlet x=5\nvar y=10"), + (subpath: "Sources/Foo.swift", contents: "// AnyLint.skipInFile: OtherRule\n\n\nlet x=5\nvar y=10"), + ] + + withTemporaryFiles(temporaryFiles) { filePathsToCheck in + let violations = try FileContentsChecker( + id: "Whitespacing", + hint: "Always add a single whitespace around '='.", + severity: .warning, + regex: Regex(#"(let|var) \w+=\w+"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + repeatIfAutoCorrected: false + ) + .performCheck() + + XCTAssertEqual(violations.count, 2) + + XCTAssertEqual(violations[0].matchedString, "let x=5") + XCTAssertEqual(violations[0].location?.filePath, "\(tempDir)/Sources/Foo.swift") + XCTAssertEqual(violations[0].location?.row, 4) + XCTAssertEqual(violations[0].location?.column, 1) + + XCTAssertEqual(violations[1].matchedString, "var y=10") + XCTAssertEqual(violations[1].location?.filePath, "\(tempDir)/Sources/Foo.swift") + XCTAssertEqual(violations[1].location?.row, 5) + XCTAssertEqual(violations[1].location?.column, 1) + } + } + + func testSkipHere() { + let temporaryFiles: [TemporaryFile] = [ + (subpath: "Sources/Hello.swift", contents: "// AnyLint.skipHere: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10"), + (subpath: "Sources/World.swift", contents: "\n\n// AnyLint.skipHere: OtherRule, Whitespacing\nlet x=5\nvar y=10"), + ( + subpath: "Sources/Foo.swift", contents: "\n\n\nlet x=5\nvar y=10 // AnyLint.skipHere: OtherRule, Whitespacing\n" + ), + (subpath: "Sources/Bar.swift", contents: "\n\n\nlet x=5\nvar y=10\n// AnyLint.skipHere: OtherRule, Whitespacing"), + ] + + withTemporaryFiles(temporaryFiles) { filePathsToCheck in + let violations = try FileContentsChecker( + id: "Whitespacing", + hint: "Always add a single whitespace around '='.", + severity: .warning, + regex: Regex(#"(let|var) \w+=\w+"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + repeatIfAutoCorrected: false + ) + .performCheck() + + XCTAssertEqual(violations.count, 6) + + XCTAssertEqual(violations[0].matchedString, "let x=5") + XCTAssertEqual(violations[0].location?.filePath, "\(tempDir)/Sources/Hello.swift") + XCTAssertEqual(violations[0].location?.row, 4) + XCTAssertEqual(violations[0].location?.column, 1) + + XCTAssertEqual(violations[1].matchedString, "var y=10") + XCTAssertEqual(violations[1].location?.filePath, "\(tempDir)/Sources/Hello.swift") + XCTAssertEqual(violations[1].location?.row, 5) + XCTAssertEqual(violations[1].location?.column, 1) + + XCTAssertEqual(violations[2].matchedString, "var y=10") + XCTAssertEqual(violations[2].location?.filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[2].location?.row, 5) + XCTAssertEqual(violations[2].location?.column, 1) + + XCTAssertEqual(violations[3].matchedString, "let x=5") + XCTAssertEqual(violations[3].location?.filePath, "\(tempDir)/Sources/Foo.swift") + XCTAssertEqual(violations[3].location?.row, 4) + XCTAssertEqual(violations[3].location?.column, 1) + + XCTAssertEqual(violations[4].matchedString, "let x=5") + XCTAssertEqual(violations[4].location?.filePath, "\(tempDir)/Sources/Bar.swift") + XCTAssertEqual(violations[4].location?.row, 4) + XCTAssertEqual(violations[4].location?.column, 1) + + XCTAssertEqual(violations[5].matchedString, "var y=10") + XCTAssertEqual(violations[5].location?.filePath, "\(tempDir)/Sources/Bar.swift") + XCTAssertEqual(violations[5].location?.row, 5) + XCTAssertEqual(violations[5].location?.column, 1) + } + } + + func testSkipIfEqualsToAutocorrectReplacement() { + 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 violations = try FileContentsChecker( + id: "Whitespacing", + hint: "Always add a single whitespace around '='.", + severity: .warning, + regex: Regex(#"(let|var) (\w+)\s*=\s*(\w+)"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: "$1 $2 = $3", + repeatIfAutoCorrected: false + ) + .performCheck() + + XCTAssertEqual(violations.count, 2) + + XCTAssertEqual(violations[0].matchedString, "let x =5") + XCTAssertEqual(violations[0].location?.filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[0].location?.row, 1) + XCTAssertEqual(violations[0].location?.column, 1) + + XCTAssertEqual(violations[1].matchedString, "var y= 10") + XCTAssertEqual(violations[1].location?.filePath, "\(tempDir)/Sources/World.swift") + XCTAssertEqual(violations[1].location?.row, 2) + XCTAssertEqual(violations[1].location?.column, 1) + } + } + + func testRepeatIfAutoCorrected() { + let temporaryFiles: [TemporaryFile] = [ + (subpath: "Sources/Hello.swift", contents: "let x = 500\nvar y = 10000"), + (subpath: "Sources/World.swift", contents: "let x = 50000000\nvar y = 100000000000000"), + ] + + withTemporaryFiles(temporaryFiles) { filePathsToCheck in + let violations = try FileContentsChecker( + id: "LongNumbers", + hint: "Format long numbers with `_` after each triple of digits from the right.", + severity: .warning, + regex: Regex(#"(? FilePathsChecker { + FilePathsChecker( + id: "say_hello", + hint: "Should always say hello.", + severity: .info, + regex: try! Regex(#".*Hello\.swift"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + violateIfNoMatchesFound: true + ) + } + + private func noWorldChecker(filePathsToCheck: [String]) -> FilePathsChecker { + FilePathsChecker( + id: "no_world", + hint: "Do not include the global world, be more specific instead.", + severity: .error, + regex: try! Regex(#".*World\.swift"#), + filePathsToCheck: filePathsToCheck, + autoCorrectReplacement: nil, + violateIfNoMatchesFound: false + ) + } +} diff --git a/Tests/CheckersTests/FilesSearchTests.swift b/Tests/CheckersTests/FilesSearchTests.swift new file mode 100644 index 0000000..4f373e7 --- /dev/null +++ b/Tests/CheckersTests/FilesSearchTests.swift @@ -0,0 +1,59 @@ +@testable import Checkers +import XCTest +import Core + +final class FilesSearchTests: XCTestCase { + 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.shared + .allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: [try Regex("\(tempDir)/.*")], + excludeFilters: [] + ) + .sorted() + XCTAssertEqual(includeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift", "\(tempDir)/Sources/World.swift"]) + + let excludeFilterFilePaths = FilesSearch.shared.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: [try Regex("\(tempDir)/.*")], + excludeFilters: [try Regex("World")] + ) + XCTAssertEqual(excludeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift"]) + } + } + + func testPerformanceOfSameSearchOptions() { + let swiftSourcesFilePaths = (0...800) + .map { (subpath: "Sources/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } + let testsFilePaths = (0...400).map { (subpath: "Tests/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } + let storyboardSourcesFilePaths = (0...300) + .map { (subpath: "Sources/Foo\($0).storyboard", contents: "Lorem ipsum\ndolor sit amet\n") } + + withTemporaryFiles(swiftSourcesFilePaths + testsFilePaths + storyboardSourcesFilePaths) { _ in + let fileSearchCode: () -> [String] = { + FilesSearch.shared.allFiles( + within: FileManager.default.currentDirectoryPath, + includeFilters: [try! Regex(#"\#(self.tempDir)/Sources/Foo.*"#)], + excludeFilters: [try! Regex(#"\#(self.tempDir)/.*\.storyboard"#)] + ) + } + + // first run + XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) + + measure { + // subsequent runs (should be much faster) + XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) + XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) + } + } + } +} diff --git a/Tests/CheckersTests/LintTests.swift b/Tests/CheckersTests/LintTests.swift new file mode 100644 index 0000000..a49b228 --- /dev/null +++ b/Tests/CheckersTests/LintTests.swift @@ -0,0 +1,328 @@ +@testable import Checkers +import XCTest +import Core +import TestSupport +import CustomDump +import Reporting + +final class LintTests: XCTestCase { + var testLogger: TestLogger = .init() + + override func setUp() { + testLogger = TestLogger() + log = testLogger + } + + func testValidateRegexMatchesForEach() { + XCTAssertNil(testLogger.exitStatusCode) + + let regex = try! Regex(#"foo[0-9]?bar"#) + let check = Check(id: "foo_bar", hint: "do bar", severity: .warning) + + Lint.validate( + regex: regex, + matchesForEach: ["foo1bar", "foobar", "myfoo4barbeque"], + check: check + ) + XCTAssertNil(testLogger.exitStatusCode) + + // TODO: [cg_2021-09-05] Swift / XCTest doesn't have a way to test for functions returning `Never` + // Lint.validate( + // regex: regex, + // matchesForEach: ["foo1bar", "FooBar", "myfoo4barbeque"], + // check: check + // ) + // XCTAssertEqual(testLogger.exitStatusCode, EXIT_FAILURE) + } + + func testValidateRegexDoesNotMatchAny() { + XCTAssertNil(testLogger.exitStatusCode) + + let regex = try! Regex(#"foo[0-9]?bar"#) + let check = Check(id: "foo_bar", hint: "do bar", severity: .warning) + + Lint.validate( + regex: regex, + doesNotMatchAny: ["fooLbar", "FooBar", "myfoo40barbeque"], + check: check + ) + XCTAssertNil(testLogger.exitStatusCode) + + // TODO: [cg_2021-09-05] Swift / XCTest doesn't have a way to test for functions returning `Never` + // Lint.validate( + // regex: regex, + // doesNotMatchAny: ["fooLbar", "foobar", "myfoo40barbeque"], + // check: check + // ) + // XCTAssertEqual(testLogger.exitStatusCode, EXIT_FAILURE) + } + + func testValidateAutocorrectsAllExamplesWithAnonymousGroups() { + XCTAssertNil(testLogger.exitStatusCode) + + let anonymousCaptureRegex = try? Regex(#"([^\.]+)\.([^\.]+)\.([^\.]+)"#) + + Lint.validateAutocorrectsAllExamples( + check: Check(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: "$3.$2.$1" + ) + + XCTAssertNil(testLogger.exitStatusCode) + + // TODO: [cg_2021-09-05] Swift / XCTest doesn't have a way to test for functions returning `Never` + // Lint.validateAutocorrectsAllExamples( + // check: Check(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: "$2.$1.$0" + // ) + // + // XCTAssertEqual(testLogger.exitStatusCode, EXIT_FAILURE) + } + + func testValidateAutocorrectsAllExamplesWithNamedGroups() { + XCTAssertNil(testLogger.exitStatusCode) + + let namedCaptureRegex = try! Regex(#"(?[^\.]+)\.(?[^\.]+)\.(?[^\.]+)"#) + + Lint.validateAutocorrectsAllExamples( + check: Check(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.$content.$prefix" + ) + + XCTAssertNil(testLogger.exitStatusCode) + + // TODO: [cg_2021-09-05] Swift / XCTest doesn't have a way to test for functions returning `Never` + // Lint.validateAutocorrectsAllExamples( + // check: Check(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.$cnt.$pref" + // ) + // + // XCTAssertEqual(testLogger.exitStatusCode, EXIT_FAILURE) + } + + func testRunCustomScript() throws { + var lintResults: LintResults = try Lint.runCustomScript( + check: .init(id: "1", hint: "hint #1"), + command: #""" + if which echo > /dev/null; then + echo 'Executed custom checks with following result: + { + "warning": { + "A@warning: hint for A": [ + { "discoverDate": "2001-01-01T00:00:00Z" }, + { "discoverDate" : "2001-01-01T01:00:00Z", "matchedString": "A" }, + { + "discoverDate" : "2001-01-01T02:00:00Z", + "matchedString": "AAA", + "location": { "filePath": "\/some\/path", "row": 5 }, + "appliedAutoCorrection": { "before": "AAA", "after": "A" } + } + ] + }, + "info": { + "B@info: hint for B": [] + } + } + + Total: 0 errors, 3 warnings, 0 info.' + fi + + """# + ) + + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["A", "B"]) + XCTAssertEqual(lintResults.allFoundViolations.count, 3) + XCTAssertNoDifference(lintResults.allFoundViolations.map(\.matchedString), [nil, "A", "AAA"]) + XCTAssertEqual(lintResults.allFoundViolations[2].location?.filePath, "/some/path") + XCTAssertEqual(lintResults.allFoundViolations[2].location?.row, 5) + XCTAssertEqual(lintResults.allFoundViolations[2].appliedAutoCorrection?.after, "A") + XCTAssertNil(lintResults.checkViolationsBySeverity[.error]?.keys.first) + XCTAssertEqual(lintResults.checkViolationsBySeverity[.info]?.keys.first?.id, "B") + + lintResults = try Lint.runCustomScript( + check: .init(id: "1", hint: "hint #1", severity: .info), + command: #""" + if which echo > /dev/null; then + echo 'Executed custom check with following violations: + [ + { "discoverDate": "2001-01-01T00:00:00Z" }, + { "discoverDate" : "2001-01-01T01:00:00Z", "matchedString": "A" }, + { + "discoverDate" : "2001-01-01T02:00:00Z", + "matchedString": "AAA", + "location": { "filePath": "\/some\/path", "row": 5 }, + "appliedAutoCorrection": { "before": "AAA", "after": "A" } + } + ] + + Total: 0 errors, 3 warnings, 0 info.' + fi + + """# + ) + + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["1"]) + XCTAssertEqual(lintResults.allFoundViolations.count, 3) + XCTAssertNoDifference(lintResults.allFoundViolations.map(\.matchedString), [nil, "A", "AAA"]) + XCTAssertEqual(lintResults.allFoundViolations[2].location?.filePath, "/some/path") + XCTAssertEqual(lintResults.allFoundViolations[2].location?.row, 5) + XCTAssertEqual(lintResults.allFoundViolations[2].appliedAutoCorrection?.after, "A") + XCTAssertNil(lintResults.checkViolationsBySeverity[.error]?.keys.first) + XCTAssertEqual(lintResults.checkViolationsBySeverity[.info]?.keys.first?.id, "1") + + lintResults = try Lint.runCustomScript( + check: .init(id: "1", hint: "hint #1", severity: .info), + command: + "echo 'Executed custom check with 100 files.\nCustom check failed, please check file at path /some/path.' && exit 1" + ) + + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["1"]) + XCTAssertEqual(lintResults.allFoundViolations.count, 1) + XCTAssertNoDifference( + lintResults.allFoundViolations.map(\.message), + ["Custom check failed, please check file at path /some/path."] + ) + XCTAssertNil(lintResults.checkViolationsBySeverity[.error]?.keys.first) + XCTAssertEqual(lintResults.checkViolationsBySeverity[.info]?.keys.first?.id, "1") + + lintResults = try Lint.runCustomScript( + check: .init(id: "1", hint: "hint #1", severity: .info), + command: #""" + echo 'Executed custom check with 100 files.\nNo issues found.' && exit 0 + """# + ) + + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["1"]) + XCTAssertEqual(lintResults.allFoundViolations.count, 0) + XCTAssertNoDifference(lintResults.allFoundViolations.map(\.matchedString), []) + XCTAssertNil(lintResults.checkViolationsBySeverity[.error]?.keys.first) + XCTAssertEqual(lintResults.checkViolationsBySeverity[.info]?.keys.first?.id, "1") + } + + func testValidateParameterCombinations() { + XCTAssertNoDifference(testLogger.loggedMessages, []) + + Lint.validateParameterCombinations( + check: .init(id: "1", hint: "hint #1"), + autoCorrectReplacement: nil, + autoCorrectExamples: [.init(before: "abc", after: "cba")], + violateIfNoMatchesFound: false + ) + + XCTAssertNoDifference( + testLogger.loggedMessages, + ["[warning] `autoCorrectExamples` provided for check 1 without specifying an `autoCorrectReplacement`."] + ) + + // TODO: [cg_2021-09-05] Swift / XCTest doesn't have a way to test for functions returning `Never` + // Lint.validateParameterCombinations( + // check: .init(id: "2", hint: "hint #2"), + // autoCorrectReplacement: "$3$2$1", + // autoCorrectExamples: [.init(before: "abc", after: "cba")], + // violateIfNoMatchesFound: true + // ) + // + // + // XCTAssertEqual( + // testLogger.loggedMessages.last, + // "Incompatible options specified for check 2: `autoCorrectReplacement` and `violateIfNoMatchesFound` can't be used together." + // ) + } + + func testCheckFileContents() throws { + let temporaryFiles: [TemporaryFile] = [ + ( + subpath: "Sources/Hello.swift", + contents: """ + let x = 5 + var y = 10 + """ + ), + ( + subpath: "Sources/World.swift", + contents: """ + let x=5 + var y=10 + """ + ), + ] + + withTemporaryFiles(temporaryFiles) { filePathsToCheck in + var fileContents: [String] = try filePathsToCheck.map { try String(contentsOfFile: $0) } + + XCTAssertNoDifference(temporaryFiles[0].contents, fileContents[0]) + XCTAssertNoDifference(temporaryFiles[1].contents, fileContents[1]) + + let violations = try Lint.checkFileContents( + check: .init(id: "1", hint: "hint #1"), + regex: .init(#"(let|var) (\w+)=(\w+)"#), + matchingExamples: ["let x=4"], + includeFilters: [try! Regex(#"\#(tempDir)/*"#)], + autoCorrectReplacement: "$1 $2 = $3", + autoCorrectExamples: [.init(before: "let x=4", after: "let x = 4")] + ) + + fileContents = try filePathsToCheck.map { try String(contentsOfFile: $0) } + + XCTAssertEqual(violations.count, 2) + XCTAssertNoDifference(violations.map(\.matchedString), ["let x=5", "var y=10"]) + XCTAssertNoDifference( + violations.map(\.location)[0], + Location(filePath: "\(tempDir)/Sources/World.swift", row: 1, column: 1) + ) + XCTAssertNoDifference( + violations.map(\.location)[1], + Location(filePath: "\(tempDir)/Sources/World.swift", row: 2, column: 1) + ) + XCTAssertNoDifference(fileContents[0], temporaryFiles[0].contents) + XCTAssertNoDifference( + fileContents[1], + """ + let x = 5 + var y = 10 + """ + ) + } + } + + func testCheckFilePaths() throws { + let violations = try Lint.checkFilePaths( + check: .init(id: "2", hint: "hint for #2", severity: .warning), + regex: .init(#"README\.markdown"#), + violateIfNoMatchesFound: true + ) + + XCTAssertEqual(violations.count, 1) + XCTAssertNoDifference( + violations.map(\.matchedString), + [nil] + ) + XCTAssertNoDifference( + violations.map(\.location), + [nil] + ) + XCTAssertNoDifference( + violations.map(\.appliedAutoCorrection), + [nil] + ) + } +} diff --git a/Tests/CommandsTests/AnyLintTests.swift b/Tests/CommandsTests/AnyLintTests.swift new file mode 100644 index 0000000..891ca68 --- /dev/null +++ b/Tests/CommandsTests/AnyLintTests.swift @@ -0,0 +1,8 @@ +import Foundation +import XCTest + +final class AnyLintTests: XCTestCase { + func testSample() { + XCTAssertTrue(true) // TODO: [cg_2021-07-31] not yet implemented + } +} diff --git a/Tests/ConfigurationTests/CheckConfigurationTests.swift b/Tests/ConfigurationTests/CheckConfigurationTests.swift new file mode 100644 index 0000000..a7a6cdf --- /dev/null +++ b/Tests/ConfigurationTests/CheckConfigurationTests.swift @@ -0,0 +1,8 @@ +import Foundation +import XCTest + +final class CheckConfigurationTests: XCTestCase { + func testSample() { + XCTAssertTrue(true) // TODO: [cg_2021-07-31] not yet implemented + } +} diff --git a/Tests/ConfigurationTests/TemplateTests.swift b/Tests/ConfigurationTests/TemplateTests.swift new file mode 100644 index 0000000..dc09392 --- /dev/null +++ b/Tests/ConfigurationTests/TemplateTests.swift @@ -0,0 +1,30 @@ +import Foundation +import XCTest +import Yams +@testable import Configuration + +final class TemplateTests: XCTestCase { + func testFileContentsNotFailing() { + for template in Template.allCases { + XCTAssertFalse(template.fileContents.isEmpty) + } + } + + func testBlankIsValidYAMLConfig() throws { + let configFileData = Template.blank.fileContents + let lintConfig: LintConfiguration = try YAMLDecoder().decode(from: configFileData) + + XCTAssert(lintConfig.filePaths.isEmpty) + XCTAssert(lintConfig.fileContents.isEmpty) + XCTAssert(lintConfig.customScripts.isEmpty) + } + + func testOpenSourceIsValidYAMLConfig() throws { + let configFileData = Template.openSource.fileContents + let lintConfig: LintConfiguration = try YAMLDecoder().decode(from: configFileData) + + XCTAssertFalse(lintConfig.filePaths.isEmpty) + XCTAssertFalse(lintConfig.fileContents.isEmpty) + XCTAssertFalse(lintConfig.customScripts.isEmpty) + } +} diff --git a/Tests/CoreTests/AutoCorrectionTests.swift b/Tests/CoreTests/AutoCorrectionTests.swift new file mode 100644 index 0000000..8672142 --- /dev/null +++ b/Tests/CoreTests/AutoCorrectionTests.swift @@ -0,0 +1,37 @@ +@testable import Core +import XCTest + +final class AutoCorrectionTests: XCTestCase { + func testInitWithDictionaryLiteral() { + let autoCorrection: AutoCorrection = .init(before: "Lisence", after: "License") + XCTAssertEqual(autoCorrection.before, "Lisence") + XCTAssertEqual(autoCorrection.after, "License") + } + + func testAppliedMessageLines() { + let singleLineAutoCorrection: AutoCorrection = .init(before: "Lisence", after: "License") + XCTAssertEqual( + singleLineAutoCorrection.appliedMessageLines, + [ + "Autocorrection applied, the diff is: (+ added, - removed)", + "- Lisence", + "+ License", + ] + ) + + let multiLineAutoCorrection: AutoCorrection = .init( + before: "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\n", + after: "A\nB\nD\nE\nF1\nF2\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ\n" + ) + XCTAssertEqual( + multiLineAutoCorrection.appliedMessageLines, + [ + "Autocorrection applied, the diff is: (+ added, - removed)", + "- [L3] C", + "+ [L5] F1", + "- [L6] F", + "+ [L6] F2", + ] + ) + } +} diff --git a/Tests/CoreTests/CheckInfoTests.swift b/Tests/CoreTests/CheckInfoTests.swift new file mode 100644 index 0000000..f8961e0 --- /dev/null +++ b/Tests/CoreTests/CheckInfoTests.swift @@ -0,0 +1,26 @@ +@testable import Core +import XCTest + +final class CheckTests: XCTestCase { + func testInit() { + let check = Check(id: "SampleId", hint: "Some hint.", severity: .warning) + XCTAssertEqual(check.id, "SampleId") + XCTAssertEqual(check.hint, "Some hint.") + XCTAssertEqual(check.severity, .warning) + + XCTAssertEqual(Check(id: "id", hint: "hint").severity, .error) + } + + func testCodable() throws { + let check = Check(id: "SampleId", hint: "Some hint.", severity: .warning) + let encodedData = try JSONEncoder().encode(check) + let encodedString = String(data: encodedData, encoding: .utf8)! + + XCTAssertEqual(encodedString, #""SampleId@warning: Some hint.""#) + + let decodedCheck = try JSONDecoder().decode(Check.self, from: encodedData) + XCTAssertEqual(decodedCheck.id, "SampleId") + XCTAssertEqual(decodedCheck.hint, "Some hint.") + XCTAssertEqual(decodedCheck.severity, .warning) + } +} diff --git a/Tests/CoreTests/RegexTests.swift b/Tests/CoreTests/RegexTests.swift new file mode 100644 index 0000000..4480c46 --- /dev/null +++ b/Tests/CoreTests/RegexTests.swift @@ -0,0 +1,203 @@ +import Foundation +import XCTest +@testable import Core + +class RegexTests: XCTestCase { + // MARK: - Initialization + func testValidInitialization() { + XCTAssertNoThrow({ try Regex("abc") }) // swiftlint:disable:this trailing_closure + } + + func testInvalidInitialization() { + do { + _ = try Regex("*") + XCTFail("Regex initialization unexpectedly didn't fail") + } + catch {} + } + + // MARK: - Options + func testOptions() { + let regexOptions1: Regex.Options = [ + .ignoreCase, .ignoreMetacharacters, .anchorsMatchLines, .dotMatchesLineSeparators, + ] + let nsRegexOptions1: NSRegularExpression.Options = [ + .caseInsensitive, .ignoreMetacharacters, .anchorsMatchLines, .dotMatchesLineSeparators, + ] + + let regexOptions2: Regex.Options = [.ignoreMetacharacters] + let nsRegexOptions2: NSRegularExpression.Options = [.ignoreMetacharacters] + + let regexOptions3: Regex.Options = [] + let nsRegexOptions3: NSRegularExpression.Options = [] + + XCTAssertEqual(regexOptions1.toNSRegularExpressionOptions, nsRegexOptions1) + XCTAssertEqual(regexOptions2.toNSRegularExpressionOptions, nsRegexOptions2) + XCTAssertEqual(regexOptions3.toNSRegularExpressionOptions, nsRegexOptions3) + } + + // MARK: - Matching + func testMatchesBool() { + let regex = try? Regex("[1-9]+") + XCTAssertTrue(regex!.matches("5")) + } + + func testFirstMatch() { + let regex = try? Regex("[1-9]?+") + XCTAssertEqual(regex?.firstMatch(in: "5 3 7")?.string, "5") + } + + func testMatches() { + let regex = try? Regex("[1-9]+") + XCTAssertEqual(regex?.matches(in: "5 432 11").map { $0.string }, ["5", "432", "11"]) + + let key = "bi" + let complexRegex = try? Regex(#"<\#(key)>([^<>]+)"#) + XCTAssertEqual( + complexRegex? + .matches( + in: + "Add all your tasks in here. We will guide you with the right questions to get them organized." + ) + .map { $0.string }, + ["tasks", "organized"] + ) + } + + func testReplacingMatches() { + let regex = try? Regex("([1-9]+)") + + let stringAfterReplace1 = regex?.replacingMatches(in: "5 3 7", with: "2") + let stringAfterReplace2 = regex?.replacingMatches(in: "5 3 7", with: "$1") + let stringAfterReplace3 = regex?.replacingMatches(in: "5 3 7", with: "1$1,") + let stringAfterReplace4 = regex?.replacingMatches(in: "5 3 7", with: "2", count: 5) + let stringAfterReplace5 = regex?.replacingMatches(in: "5 3 7", with: "2", count: 2) + + XCTAssertEqual(stringAfterReplace1, "2 2 2") + XCTAssertEqual(stringAfterReplace2, "5 3 7") + XCTAssertEqual(stringAfterReplace3, "15, 13, 17,") + XCTAssertEqual(stringAfterReplace4, "2 2 2") + XCTAssertEqual(stringAfterReplace5, "2 2 7") + } + + func testReplacingMatchesWithSpecialCharacters() { + let testString = "\nSimuliere, wie gut ein \\nE-Fahrzeug zu dir passt\n" + let newValue = "Simuliere, wie gut ein \\nE-Fahrzeug zu dir passt2" + let expectedResult = + "\nSimuliere, wie gut ein \\nE-Fahrzeug zu dir passt2\n" + + let regex = try? Regex("(]* name=\"nav_menu_sim_info\"[^>]*>)(.*)()") + let stringAfterReplace1 = regex? + .replacingMatches(in: testString, with: "$1\(NSRegularExpression.escapedTemplate(for: newValue))$3") + + XCTAssertEqual(stringAfterReplace1, expectedResult) + } + + // MARK: - Match + func testMatchString() { + let regex = try? Regex("[1-9]+") + let firstMatchString = regex?.firstMatch(in: "abc5def")?.string + XCTAssertEqual(firstMatchString, "5") + } + + func testMatchRange() { + let regex = try? Regex("[1-9]+") + let text = "abc5def" + let firstMatchRange = regex?.firstMatch(in: text)?.range + XCTAssertEqual(firstMatchRange?.lowerBound.utf16Offset(in: text), 3) + XCTAssertEqual(firstMatchRange?.upperBound.utf16Offset(in: text), 4) + } + + func testMatchCaptures() { + let regex = try? Regex("([1-9])(Needed)(Optional)?") + let match1 = regex?.firstMatch(in: "2Needed") + let match2 = regex?.firstMatch(in: "5NeededOptional") + + enum CapturingError: Error { + case indexTooHigh + case noMatch + } + + func captures(at index: Int, forMatch match: Regex.Match?) throws -> String? { + guard let captures = match?.captures else { throw CapturingError.noMatch } + guard captures.count > index else { throw CapturingError.indexTooHigh } + return captures[index] + } + + do { + let match1Capture0 = try captures(at: 0, forMatch: match1) + let match1Capture1 = try captures(at: 1, forMatch: match1) + let match1Capture2 = try captures(at: 2, forMatch: match1) + + let match2Capture0 = try captures(at: 0, forMatch: match2) + let match2Capture1 = try captures(at: 1, forMatch: match2) + let match2Capture2 = try captures(at: 2, forMatch: match2) + + XCTAssertEqual(match1Capture0, "2") + XCTAssertEqual(match1Capture1, "Needed") + XCTAssertNil(match1Capture2) + + XCTAssertEqual(match2Capture0, "5") + XCTAssertEqual(match2Capture1, "Needed") + XCTAssertEqual(match2Capture2, "Optional") + } + catch let error as CapturingError { + switch error { + case .indexTooHigh: + XCTFail("Capturing group index is too high.") + + case .noMatch: + XCTFail("The match is nil.") + } + } + catch { + XCTFail("An unexpected error occured.") + } + } + + func testMatchStringApplyingTemplate() { + let regex = try? Regex("([1-9])(Needed)") + let match = regex?.firstMatch(in: "1Needed") + XCTAssertEqual(match?.string(applyingTemplate: "Test$1ThatIs$2"), "Test1ThatIsNeeded") + } + + // MARK: - Equatable + func testEquatable() { + do { + let regex1 = try Regex("abc") + let regex2 = try Regex("abc") + let regex3 = try Regex("cba") + let regex4 = try Regex("abc", options: [.ignoreCase]) + let regex5 = regex1 + + XCTAssertEqual(regex1, regex2) + XCTAssertNotEqual(regex1, regex3) + XCTAssertNotEqual(regex1, regex4) + XCTAssertEqual(regex1, regex5) + + XCTAssertNotEqual(regex2, regex3) + XCTAssertNotEqual(regex2, regex4) + XCTAssertEqual(regex2, regex5) + + XCTAssertNotEqual(regex3, regex4) + XCTAssertNotEqual(regex3, regex5) + + XCTAssertNotEqual(regex4, regex5) + } + catch { + XCTFail("Sample Regex creation failed.") + } + } + + // MARK: - CustomStringConvertible + func testRegexCustomStringConvertible() { + let regex = try? Regex("foo") + XCTAssertEqual(regex?.description, #"/foo/"#) + } + + func testMatchCustomStringConvertible() { + let regex = try? Regex("bar") + let match = regex?.firstMatch(in: "bar")! + XCTAssertEqual(match?.description, #"Match<"bar">"#) + } +} diff --git a/Tests/CoreTests/SeverityTests.swift b/Tests/CoreTests/SeverityTests.swift new file mode 100644 index 0000000..a001bd6 --- /dev/null +++ b/Tests/CoreTests/SeverityTests.swift @@ -0,0 +1,16 @@ +@testable import Core +import XCTest + +final class SeverityTests: XCTestCase { + func testComparable() throws { + XCTAssert(Severity.error > Severity.warning) + XCTAssert(Severity.warning > Severity.info) + XCTAssert(Severity.error > Severity.info) + XCTAssert(Severity.warning < Severity.error) + XCTAssert(Severity.info < Severity.warning) + XCTAssert(Severity.info < Severity.error) + XCTAssert(Severity.error == Severity.error) + XCTAssert(Severity.warning == Severity.warning) + XCTAssert(Severity.info == Severity.info) + } +} diff --git a/Tests/CoreTests/ViolationTests.swift b/Tests/CoreTests/ViolationTests.swift new file mode 100644 index 0000000..e9425a8 --- /dev/null +++ b/Tests/CoreTests/ViolationTests.swift @@ -0,0 +1,17 @@ +@testable import Core +import XCTest + +final class ViolationTests: XCTestCase { + func testLocationMessage() { + XCTAssertNil(Violation().location?.locationMessage(pathType: .relative)) + + let fileViolation = Violation(location: .init(filePath: "Temp/Sources/Hello.swift")) + XCTAssertEqual(fileViolation.location?.locationMessage(pathType: .relative), "Temp/Sources/Hello.swift") + + let locationInfoViolation = Violation(location: .init(filePath: "Temp/Sources/World.swift", row: 5, column: 15)) + XCTAssertEqual( + locationInfoViolation.location?.locationMessage(pathType: .relative), + "Temp/Sources/World.swift:5:15:" + ) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index ae7fdcc..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,92 +0,0 @@ -// Generated using Sourcery 0.18.0 — https://github.com/krzysztofzablocki/Sourcery -// DO NOT EDIT - -@testable import AnyLintTests -@testable import Utility -import XCTest - -// swiftlint:disable line_length file_length - -extension ArrayExtTests { - static var allTests: [(String, (ArrayExtTests) -> () throws -> Void)] = [ - ("testContainsLineAtIndexesMatchingRegex", testContainsLineAtIndexesMatchingRegex) - ] -} - -extension AutoCorrectionTests { - static var allTests: [(String, (AutoCorrectionTests) -> () throws -> Void)] = [ - ("testInitWithDictionaryLiteral", testInitWithDictionaryLiteral), - ("testAppliedMessageLines", testAppliedMessageLines) - ] -} - -extension CheckInfoTests { - static var allTests: [(String, (CheckInfoTests) -> () throws -> Void)] = [ - ("testInitWithStringLiteral", testInitWithStringLiteral) - ] -} - -extension FileContentsCheckerTests { - static var allTests: [(String, (FileContentsCheckerTests) -> () throws -> Void)] = [ - ("testPerformCheck", testPerformCheck), - ("testSkipInFile", testSkipInFile), - ("testSkipHere", testSkipHere), - ("testSkipIfEqualsToAutocorrectReplacement", testSkipIfEqualsToAutocorrectReplacement), - ("testRepeatIfAutoCorrected", testRepeatIfAutoCorrected) - ] -} - -extension FilePathsCheckerTests { - static var allTests: [(String, (FilePathsCheckerTests) -> () throws -> Void)] = [ - ("testPerformCheck", testPerformCheck) - ] -} - -extension FilesSearchTests { - static var allTests: [(String, (FilesSearchTests) -> () throws -> Void)] = [ - ("testAllFilesWithinPath", testAllFilesWithinPath), - ("testPerformanceOfSameSearchOptions", testPerformanceOfSameSearchOptions) - ] -} - -extension LintTests { - static var allTests: [(String, (LintTests) -> () throws -> Void)] = [ - ("testValidateRegexMatchesForEach", testValidateRegexMatchesForEach), - ("testValidateRegexDoesNotMatchAny", testValidateRegexDoesNotMatchAny), - ("testValidateAutocorrectsAllExamplesWithAnonymousGroups", testValidateAutocorrectsAllExamplesWithAnonymousGroups), - ("testValidateAutocorrectsAllExamplesWithNamedGroups", testValidateAutocorrectsAllExamplesWithNamedGroups) - ] -} - -extension RegexExtTests { - static var allTests: [(String, (RegexExtTests) -> () throws -> Void)] = [ - ("testInitWithStringLiteral", testInitWithStringLiteral), - ("testInitWithDictionaryLiteral", testInitWithDictionaryLiteral) - ] -} - -extension StatisticsTests { - static var allTests: [(String, (StatisticsTests) -> () throws -> Void)] = [ - ("testFoundViolationsInCheck", testFoundViolationsInCheck), - ("testLogSummary", testLogSummary) - ] -} - -extension ViolationTests { - static var allTests: [(String, (ViolationTests) -> () throws -> Void)] = [ - ("testLocationMessage", testLocationMessage) - ] -} - -XCTMain([ - testCase(ArrayExtTests.allTests), - testCase(AutoCorrectionTests.allTests), - testCase(CheckInfoTests.allTests), - testCase(FileContentsCheckerTests.allTests), - testCase(FilePathsCheckerTests.allTests), - testCase(FilesSearchTests.allTests), - testCase(LintTests.allTests), - testCase(RegexExtTests.allTests), - testCase(StatisticsTests.allTests), - testCase(ViolationTests.allTests) -]) diff --git a/Tests/ReportingTests/LintResultsTests.swift b/Tests/ReportingTests/LintResultsTests.swift new file mode 100644 index 0000000..e9ccebd --- /dev/null +++ b/Tests/ReportingTests/LintResultsTests.swift @@ -0,0 +1,337 @@ +@testable import Reporting +import Core +import XCTest +import CustomDump +import TestSupport + +final class LintResultsTests: XCTestCase { + private var sampleLintResults: LintResults { + [ + Severity.error: [ + Check(id: "1", hint: "hint #1", severity: .error): [ + Violation( + discoverDate: .sample(seed: 0), + matchedString: "oink1", + location: .init(filePath: "/sample/path1", row: 4, column: 2) + ), + Violation( + discoverDate: .sample(seed: 1), + matchedString: "boo1", + location: .init(filePath: "/sample/path2", row: 40, column: 20) + ), + Violation( + discoverDate: .sample(seed: 2), + location: .init(filePath: "/sample/path2"), + appliedAutoCorrection: .init(before: "foo", after: "bar") + ), + ] + ], + Severity.warning: [ + Check(id: "2", hint: "hint #2", severity: .warning): [ + Violation( + discoverDate: .sample(seed: 3), + matchedString: "oink2", + location: .init(filePath: "/sample/path1", row: 5, column: 6) + ), + Violation( + discoverDate: .sample(seed: 4), + matchedString: "boo2", + location: .init(filePath: "/sample/path3", row: 50, column: 60) + ), + Violation( + discoverDate: .sample(seed: 5), + location: .init(filePath: "/sample/path4"), + appliedAutoCorrection: .init(before: "fool", after: "barl") + ), + ] + ], + Severity.info: [ + Check(id: "3", hint: "hint #3", severity: .info): [ + Violation( + discoverDate: .sample(seed: 6), + matchedString: "blubb", + location: .init(filePath: "/sample/path0", row: 10, column: 20) + ) + ] + ], + ] + } + + func testAllExecutedChecks() { + let allExecutedChecks = sampleLintResults.allExecutedChecks + XCTAssertNoDifference(allExecutedChecks.count, 3) + XCTAssertNoDifference(allExecutedChecks.map(\.id), ["1", "2", "3"]) + } + + func testAllFoundViolations() { + let allFoundViolations = sampleLintResults.allFoundViolations + XCTAssertNoDifference(allFoundViolations.count, 7) + XCTAssertNoDifference( + allFoundViolations.map(\.location).map(\.?.filePath).map(\.?.last), + ["1", "2", "2", "1", "3", "4", "0"] + ) + XCTAssertNoDifference( + allFoundViolations.map(\.matchedString), + ["oink1", "boo1", nil, "oink2", "boo2", nil, "blubb"] + ) + } + + func testMergeResults() { + let otherLintResults: LintResults = [ + Severity.error: [ + Check(id: "1", hint: "hint #1", severity: .warning): [ + Violation(matchedString: "muuh", location: .init(filePath: "/sample/path4", row: 6, column: 3)), + Violation( + location: .init(filePath: "/sample/path5"), + appliedAutoCorrection: .init(before: "fusion", after: "wario") + ), + ], + Check(id: "2", hint: "hint #2 (alternative)", severity: .warning): [], + Check(id: "4", hint: "hint #4", severity: .error): [ + Violation(matchedString: "super", location: .init(filePath: "/sample/path1", row: 2, column: 200)) + ], + ] + ] + + var lintResults = sampleLintResults + lintResults.mergeResults(otherLintResults) + let allExecutedChecks = lintResults.allExecutedChecks + let allFoundViolations = lintResults.allFoundViolations + + XCTAssertNoDifference(allExecutedChecks.count, 6) + XCTAssertNoDifference(allExecutedChecks.map(\.id), ["1", "1", "2", "2", "3", "4"]) + + XCTAssertNoDifference(allFoundViolations.count, 10) + XCTAssertNoDifference( + allFoundViolations.map(\.location).map(\.?.filePath).map(\.?.last), + ["1", "2", "2", "1", "3", "4", "0", "4", "5", "1"] + ) + XCTAssertNoDifference( + allFoundViolations.map(\.matchedString), + ["oink1", "boo1", nil, "oink2", "boo2", nil, "blubb", "muuh", nil, "super"] + ) + } + + func testAppendViolations() { + // TODO: [cg_2021-09-01] not yet implemented + var lintResults = sampleLintResults + + XCTAssertNoDifference(lintResults.allFoundViolations.count, 7) + XCTAssertNoDifference(lintResults.allExecutedChecks.count, 3) + XCTAssertNoDifference( + lintResults.allFoundViolations.map(\.matchedString).map(\.?.first), + ["o", "b", nil, "o", "b", nil, "b"] + ) + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["1", "2", "3"]) + + lintResults.appendViolations( + [ + Violation(matchedString: "A", location: .init(filePath: "/sample/path5", row: 7, column: 7)), + Violation(matchedString: "B", location: .init(filePath: "/sample/path6", row: 70, column: 70)), + Violation( + location: .init(filePath: "/sample/path7"), + appliedAutoCorrection: .init(before: "C", after: "D") + ), + ], + forCheck: .init(id: "Added", hint: "hint for added") + ) + + XCTAssertNoDifference(lintResults.allFoundViolations.count, 10) + XCTAssertNoDifference(lintResults.allExecutedChecks.count, 4) + XCTAssertNoDifference( + lintResults.allFoundViolations.map(\.matchedString).map(\.?.first), + ["o", "b", nil, "o", "b", nil, "b", "A", "B", nil] + ) + XCTAssertNoDifference(lintResults.allExecutedChecks.map(\.id), ["1", "2", "3", "Added"]) + } + + func testReportToConsole() { + let testLogger = TestLogger() + log = testLogger + + XCTAssertNoDifference(testLogger.loggedMessages, []) + sampleLintResults.reportToConsole() + + XCTAssertNoDifference( + testLogger.loggedMessages, + [ + "[error] [1] Found 3 violation(s) at:", + "[error] > 1. /sample/path1:4:2:", + "[info] Matching string: (trimmed & reduced whitespaces)", + "[info] > oink1", + "[error] > 2. /sample/path2:40:20:", + "[info] Matching string: (trimmed & reduced whitespaces)", + "[info] > boo1", + "[error] > 3. /sample/path2", + "[info] Autocorrection applied, the diff is: (+ added, - removed)", + "[info] - foo", + "[info] + bar", + "[error] >> Hint: hint #1", + "[warning] [2] Found 3 violation(s) at:", + "[warning] > 1. /sample/path1:5:6:", + "[info] Matching string: (trimmed & reduced whitespaces)", + "[info] > oink2", + "[warning] > 2. /sample/path3:50:60:", + "[info] Matching string: (trimmed & reduced whitespaces)", + "[info] > boo2", + "[warning] > 3. /sample/path4", + "[info] Autocorrection applied, the diff is: (+ added, - removed)", + "[info] - fool", + "[info] + barl", + "[warning] >> Hint: hint #2", + "[info] [3] Found 1 violation(s) at:", + "[info] > 1. /sample/path0:10:20:", + "[info] Matching string: (trimmed & reduced whitespaces)", + "[info] > blubb", + "[info] >> Hint: hint #3", + "[error] Performed 3 check(s) and found 3 error(s) & 3 warning(s).", + ] + ) + } + + func testReportToXcode() { + let testLogger = TestLogger() + log = testLogger + + XCTAssertNoDifference(testLogger.loggedMessages, []) + sampleLintResults.reportToXcode() + + XCTAssertNoDifference( + testLogger.loggedMessages, + [ + "[error] /sample/path1:4:2: [1] hint #1", + "[error] /sample/path2:40:20: [1] hint #1", + "[warning] /sample/path1:5:6: [2] hint #2", + "[warning] /sample/path3:50:60: [2] hint #2", + "[info] /sample/path0:10:20: [3] hint #3", + ] + ) + } + + func testReportToFile() throws { + let resultFileUrl = URL(fileURLWithPath: "anylint-test-results.json") + + if FileManager.default.fileExists(atPath: resultFileUrl.path) { + try FileManager.default.removeItem(at: resultFileUrl) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: resultFileUrl.path)) + + sampleLintResults.reportToFile(at: resultFileUrl.path) + XCTAssert(FileManager.default.fileExists(atPath: resultFileUrl.path)) + + let reportedContents = try Data(contentsOf: resultFileUrl) + let reportedLintResults = try JSONDecoder.iso.decode(LintResults.self, from: reportedContents) + + XCTAssertNoDifference(sampleLintResults, reportedLintResults) + } + + func testViolations() { + let lintResults = sampleLintResults + + XCTAssertNoDifference( + lintResults.violations(severity: .warning, excludeAutocorrected: false).map(\.matchedString), + ["oink2", "boo2", nil] + ) + + XCTAssertNoDifference( + lintResults.violations(severity: .warning, excludeAutocorrected: true).map(\.matchedString), + ["oink2", "boo2"] + ) + + XCTAssertNoDifference( + lintResults + .violations(check: .init(id: "1", hint: "hint #1"), excludeAutocorrected: false).map(\.matchedString), + ["oink1", "boo1", nil] + ) + + XCTAssertNoDifference( + lintResults + .violations(check: .init(id: "1", hint: "hint #1"), excludeAutocorrected: true).map(\.matchedString), + ["oink1", "boo1"] + ) + } + + func testMaxViolationSeverity() { + var lintResults: LintResults = sampleLintResults + XCTAssertEqual(sampleLintResults.maxViolationSeverity(excludeAutocorrected: false), .error) + + lintResults = [ + Severity.error: [ + Check(id: "1", hint: "hint #1", severity: .error): [] + ], + Severity.warning: [ + Check(id: "2", hint: "hint #2", severity: .warning): [ + Violation(matchedString: "oink2", location: .init(filePath: "/sample/path1", row: 5, column: 6)), + Violation(matchedString: "boo2", location: .init(filePath: "/sample/path3", row: 50, column: 60)), + Violation( + location: .init(filePath: "/sample/path4"), + appliedAutoCorrection: .init(before: "fool", after: "barl") + ), + ] + ], + Severity.info: [ + Check(id: "3", hint: "hint #3", severity: .info): [ + Violation(matchedString: "blubb", location: .init(filePath: "/sample/path0", row: 10, column: 20)) + ] + ], + ] + XCTAssertEqual(lintResults.maxViolationSeverity(excludeAutocorrected: false), .warning) + + lintResults = [ + Severity.error: [ + Check(id: "1", hint: "hint #1", severity: .error): [] + ], + Severity.warning: [ + Check(id: "2", hint: "hint #2", severity: .warning): [] + ], + Severity.info: [ + Check(id: "3", hint: "hint #3", severity: .info): [ + Violation(matchedString: "blubb", location: .init(filePath: "/sample/path0", row: 10, column: 20)) + ] + ], + ] + XCTAssertEqual(lintResults.maxViolationSeverity(excludeAutocorrected: false), .info) + + lintResults = [ + Severity.error: [ + Check(id: "1", hint: "hint #1", severity: .error): [] + ], + Severity.warning: [ + Check(id: "2", hint: "hint #2", severity: .warning): [] + ], + Severity.info: [ + Check(id: "3", hint: "hint #3", severity: .info): [] + ], + ] + XCTAssertEqual(lintResults.maxViolationSeverity(excludeAutocorrected: false), nil) + + XCTAssertEqual(LintResults().maxViolationSeverity(excludeAutocorrected: false), nil) + } + + func testCodable() throws { + let lintResults: LintResults = [ + .warning: [ + .init(id: "1", hint: "hint for #1"): [ + .init(discoverDate: .sample(seed: 0)), + .init( + discoverDate: .sample(seed: 1), + matchedString: "A", + appliedAutoCorrection: .init(before: "AAA", after: "A") + ), + .init( + discoverDate: .sample(seed: 2), + matchedString: "AAA", + location: .init(filePath: "/some/path", row: 5, column: 2) + ), + ] + ], + .info: [:], + ] + let encodedData = try JSONEncoder.iso.encode(lintResults) + let encodedString = String(data: encodedData, encoding: .utf8)! + XCTAssert(encodedString.count > 500) + + let decodedLintResults = try JSONDecoder.iso.decode(LintResults.self, from: encodedData) + XCTAssertNoDifference(decodedLintResults, lintResults) + } +} diff --git a/Tests/UtilityTests/Extensions/RegexExtTests.swift b/Tests/UtilityTests/Extensions/RegexExtTests.swift deleted file mode 100644 index 5add852..0000000 --- a/Tests/UtilityTests/Extensions/RegexExtTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -@testable import Utility -import XCTest - -final class RegexExtTests: XCTestCase { - func testStringLiteralInit() { - let regex: Regex = #".*"# - XCTAssertEqual(regex.description, #"/.*/"#) - } - - func testStringLiteralInitWithOptions() { - let regexI: Regex = #".*\i"# - XCTAssertEqual(regexI.description, #"/.*/i"#) - - let regexM: Regex = #".*\m"# - XCTAssertEqual(regexM.description, #"/.*/m"#) - - let regexIM: Regex = #".*\im"# - XCTAssertEqual(regexIM.description, #"/.*/im"#) - - let regexMI: Regex = #".*\mi"# - XCTAssertEqual(regexMI.description, #"/.*/im"#) - } - - func testDictionaryLiteralInit() { - let regex: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#] - XCTAssertEqual(regex.description, #"/(?[a-z]+)(?\d+\.?\d*)/"#) - } - - func testDictionaryLiteralInitWithOptions() { - let regexI: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "i"] - XCTAssertEqual(regexI.description, #"/(?[a-z]+)(?\d+\.?\d*)/i"#) - - let regexM: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "m"] - XCTAssertEqual(regexM.description, #"/(?[a-z]+)(?\d+\.?\d*)/m"#) - - let regexMI: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "mi"] - XCTAssertEqual(regexMI.description, #"/(?[a-z]+)(?\d+\.?\d*)/im"#) - - let regexIM: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "im"] - XCTAssertEqual(regexIM.description, #"/(?[a-z]+)(?\d+\.?\d*)/im"#) - } - - func testReplacingMatchesInInputWithTemplate() { - let regexTrailing: Regex = #"(?<=\n)([-–] .*[^ ])( {0,1}| {3,})\n"# - let text: String = "\n- Sample Text.\n" - - XCTAssertEqual( - regexTrailing.replacingMatches(in: text, with: "$1 \n"), - "\n- Sample Text. \n" - ) - } -} diff --git a/Tests/UtilityTests/LoggerTests.swift b/Tests/UtilityTests/LoggerTests.swift deleted file mode 100644 index 3496482..0000000 --- a/Tests/UtilityTests/LoggerTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -@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 index 4265c8a..1e6e6f6 100755 --- a/lint.swift +++ b/lint.swift @@ -1,5 +1,5 @@ #!/usr/local/bin/swift-sh -import AnyLint // . +import AnyLint // @Flinesoft import Utility import ShellOut // @JohnSundell @@ -14,7 +14,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: - Checks // MARK: Changelog try Lint.checkFilePaths( - checkInfo: "Changelog: Each project should have a CHANGELOG.md file, tracking the changes within a project over time.", + check: "Changelog: Each project should have a CHANGELOG.md file, tracking the changes within a project over time.", regex: changelogFile, matchingExamples: ["CHANGELOG.md"], nonMatchingExamples: ["CHANGELOG.markdown", "Changelog.md", "ChangeLog.md"], @@ -23,7 +23,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: ChangelogEntryTrailingWhitespaces try Lint.checkFileContents( - checkInfo: "ChangelogEntryTrailingWhitespaces: The summary line of a Changelog entry should end with two whitespaces.", + check: "ChangelogEntryTrailingWhitespaces: The summary line of a Changelog entry should end with two whitespaces.", regex: #"\n([-–] (?!None\.).*[^ ])( {0,1}| {3,})\n"#, matchingExamples: ["\n- Fixed a bug.\n Issue:", "\n- Added a new option. (see [Link](#)) \nPR:"], nonMatchingExamples: ["\n- Fixed a bug. \n Issue:", "\n- Added a new option. (see [Link](#)) \nPR:"], @@ -41,7 +41,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: ChangelogEntryLeadingWhitespaces try Lint.checkFileContents( - checkInfo: "ChangelogEntryLeadingWhitespaces: The links line of a Changelog entry should start with two whitespaces.", + check: "ChangelogEntryLeadingWhitespaces: The links line of a Changelog entry should start with two whitespaces.", regex: #"\n( {0,1}| {3,})(Tasks?:|Issues?:|PRs?:|Authors?:)"#, matchingExamples: ["\n- Fixed a bug.\nIssue: [Link](#)", "\n- Fixed a bug. \nIssue: [Link](#)", "\n- Fixed a bug. \nIssue: [Link](#)"], nonMatchingExamples: ["- Fixed a bug.\n Issue: [Link](#)"], @@ -56,7 +56,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: EmptyMethodBody try Lint.checkFileContents( - checkInfo: "EmptyMethodBody: Don't use whitespace or newlines for the body of empty methods.", + check: "EmptyMethodBody: Don't use whitespace or newlines for the body of empty methods.", regex: ["declaration": #"(init|func [^\(\s]+)\([^{}]*\)"#, "spacing": #"\s*"#, "body": #"\{\s+\}"#], matchingExamples: [ "init() { }", @@ -83,7 +83,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: EmptyTodo try Lint.checkFileContents( - checkInfo: "EmptyTodo: `// TODO:` comments should not be empty.", + check: "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"], @@ -92,7 +92,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: EmptyType try Lint.checkFileContents( - checkInfo: "EmptyType: Don't keep empty types in code without commenting inside why they are needed.", + check: "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 }"], @@ -101,7 +101,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: GuardMultiline2 try Lint.checkFileContents( - checkInfo: "GuardMultiline2: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + check: "GuardMultiline2: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", regex: [ "newline": #"\n"#, "guardIndent": #" *"#, @@ -170,7 +170,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: GuardMultiline3 try Lint.checkFileContents( - checkInfo: "GuardMultiline3: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + check: "GuardMultiline3: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", regex: [ "newline": #"\n"#, "guardIndent": #" *"#, @@ -246,7 +246,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: GuardMultiline4 try Lint.checkFileContents( - checkInfo: "GuardMultiline4: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + check: "GuardMultiline4: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", regex: [ "newline": #"\n"#, "guardIndent": #" *"#, @@ -329,7 +329,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: GuardMultilineN try Lint.checkFileContents( - checkInfo: "GuardMultilineN: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", + check: "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: [ """ @@ -370,7 +370,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: IfAsGuard try Lint.checkFileContents( - checkInfo: "IfAsGuard: Don't use an if statement to just return – use guard for such cases instead.", + check: "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 {"], @@ -379,7 +379,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: LateForceUnwrapping3 try Lint.checkFileContents( - checkInfo: "LateForceUnwrapping3: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + check: "LateForceUnwrapping3: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", regex: [ "openingBrace": #"\("#, "callPart1": #"[^\s\?\.]+"#, @@ -402,7 +402,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: LateForceUnwrapping2 try Lint.checkFileContents( - checkInfo: "LateForceUnwrapping2: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + check: "LateForceUnwrapping2: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", regex: [ "openingBrace": #"\("#, "callPart1": #"[^\s\?\.]+"#, @@ -423,7 +423,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: LateForceUnwrapping1 try Lint.checkFileContents( - checkInfo: "LateForceUnwrapping1: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", + check: "LateForceUnwrapping1: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", regex: [ "openingBrace": #"\("#, "callPart1": #"[^\s\?\.]+"#, @@ -440,52 +440,9 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { ] ) - // MARK: LinuxMainUpToDate - try Lint.customCheck(checkInfo: "LinuxMainUpToDate: The tests in Tests/LinuxMain.swift should be up-to-date.") { checkInfo in - var violations: [Violation] = [] - - let linuxMainFilePath = "Tests/LinuxMain.swift" - let linuxMainContentsBeforeRegeneration = try! String(contentsOfFile: linuxMainFilePath) - - let sourceryDirPath = ".sourcery" - let testsDirPath = "Tests/\(projectName)Tests" - let stencilFilePath = "\(sourceryDirPath)/LinuxMain.stencil" - let generatedLinuxMainFilePath = "\(sourceryDirPath)/LinuxMain.generated.swift" - - let sourceryInstallPath = try? shellOut(to: "which", arguments: ["sourcery"]) - guard sourceryInstallPath != nil else { - log.message( - "Skipped custom check \(checkInfo) – requires Sourcery to be installed, download from: https://github.com/krzysztofzablocki/Sourcery", - level: .warning - ) - return [] - } - - try! shellOut(to: "sourcery", arguments: ["--sources", testsDirPath, "--templates", stencilFilePath, "--output", sourceryDirPath]) - let linuxMainContentsAfterRegeneration = try! String(contentsOfFile: generatedLinuxMainFilePath) - - // move generated file to LinuxMain path to update its contents - try! shellOut(to: "mv", arguments: [generatedLinuxMainFilePath, linuxMainFilePath]) - - if linuxMainContentsBeforeRegeneration != linuxMainContentsAfterRegeneration { - violations.append( - Violation( - checkInfo: checkInfo, - filePath: linuxMainFilePath, - appliedAutoCorrection: AutoCorrection( - before: linuxMainContentsBeforeRegeneration, - after: linuxMainContentsAfterRegeneration - ) - ) - ) - } - - return violations - } - // MARK: Logger try Lint.checkFileContents( - checkInfo: "Logger: Don't use `print` – use `log.message` instead.", + check: "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!")"#], @@ -495,7 +452,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: Readme try Lint.checkFilePaths( - checkInfo: "Readme: Each project should have a README.md file, explaining how to use or contribute to the project.", + check: "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"], @@ -504,7 +461,7 @@ try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { // MARK: ReadmePath try Lint.checkFilePaths( - checkInfo: "ReadmePath: The README file should be named exactly `README.md`.", + check: "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"],