├── Logo.png ├── .spi.yml ├── RubularQuickReference.png ├── Sources ├── AnyLintCLI │ ├── Tasks │ │ ├── TaskHandler.swift │ │ ├── VersionTask.swift │ │ ├── InitTask.swift │ │ └── LintTask.swift │ ├── main.swift │ ├── ConfigurationTemplates │ │ ├── ConfigurationTemplate.swift │ │ └── BlankTemplate.swift │ ├── Globals │ │ ├── ValidateOrFail.swift │ │ └── CLIConstants.swift │ └── Commands │ │ └── SingleCommand.swift ├── AnyLint │ ├── Checkers │ │ ├── Checker.swift │ │ ├── FilePathsChecker.swift │ │ └── FileContentsChecker.swift │ ├── Options.swift │ ├── Extensions │ │ ├── URLExt.swift │ │ ├── ArrayExt.swift │ │ ├── StringExt.swift │ │ └── FileManagerExt.swift │ ├── Severity.swift │ ├── ViolationLocationConfig.swift │ ├── Violation.swift │ ├── CheckInfo.swift │ ├── AutoCorrection.swift │ ├── FilesSearch.swift │ ├── Statistics.swift │ └── Lint.swift └── Utility │ ├── Extensions │ ├── CollectionExt.swift │ ├── FileManagerExt.swift │ ├── StringExt.swift │ └── RegexExt.swift │ ├── TestHelper.swift │ ├── Constants.swift │ ├── Logger.swift │ └── Regex.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcbaselines │ └── AnyLintTests.xcbaseline │ ├── 17124E59-2851-436E-BEA7-3FAEFD815A8B.plist │ └── Info.plist ├── Formula └── anylint.rb ├── Tests ├── AnyLintTests │ ├── RegexExtTests.swift │ ├── Extensions │ │ ├── ArrayExtTests.swift │ │ └── XCTestCaseExt.swift │ ├── ViolationTests.swift │ ├── CheckInfoTests.swift │ ├── AutoCorrectionTests.swift │ ├── FilesSearchTests.swift │ ├── Checkers │ │ ├── FilePathsCheckerTests.swift │ │ └── FileContentsCheckerTests.swift │ ├── LintTests.swift │ └── StatisticsTests.swift ├── UtilityTests │ ├── LoggerTests.swift │ └── Extensions │ │ └── RegexExtTests.swift └── LinuxMain.swift ├── .sourcery └── LinuxMain.stencil ├── Makefile ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Package.swift ├── LICENSE ├── .swiftlint.yml ├── CHANGELOG.md ├── lint.swift └── README.md /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/AnyLint/HEAD/Logo.png -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [AnyLint] 5 | -------------------------------------------------------------------------------- /RubularQuickReference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/AnyLint/HEAD/RubularQuickReference.png -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Tasks/TaskHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol TaskHandler { 4 | func perform() throws 5 | } 6 | -------------------------------------------------------------------------------- /Sources/AnyLint/Checkers/Checker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Checker { 4 | func performCheck() throws -> [Violation] 5 | } 6 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | 4 | let singleCommand = CLI(singleCommand: SingleCommand()) 5 | singleCommand.goAndExit() 6 | -------------------------------------------------------------------------------- /Sources/AnyLint/Options.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Options { 4 | static var validateOnly: Bool = false 5 | static var unvalidated: Bool = false 6 | } 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/Utility/Extensions/CollectionExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | /// A Boolean value indicating whether the collection is not empty. 5 | public var isFilled: Bool { 6 | !isEmpty 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Tasks/VersionTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | struct VersionTask { /* for extension purposes only */ } 5 | 6 | extension VersionTask: TaskHandler { 7 | func perform() throws { 8 | log.message(Constants.currentVersion, level: .info) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/AnyLint/Extensions/URLExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | extension URL { 5 | /// Returns the relative path of from the current path. 6 | public var relativePathFromCurrent: String { 7 | String(path.replacingOccurrences(of: fileManager.currentDirectoryPath, with: "").dropFirst()) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/AnyLint/Extensions/ArrayExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array where Element == String { 4 | func containsLine(at indexes: [Int], matchingRegex regex: Regex) -> Bool { 5 | indexes.contains { index in 6 | guard index >= 0, index < count else { return false } 7 | return regex.matches(self[index]) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Utility/Extensions/FileManagerExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FileManager { 4 | /// The current directory `URL`. 5 | public var currentDirectoryUrl: URL { 6 | URL(string: currentDirectoryPath)! 7 | } 8 | 9 | /// Checks if a file exists and the given paths and is a directory. 10 | public func fileExistsAndIsDirectory(atPath path: String) -> Bool { 11 | var isDirectory: ObjCBool = false 12 | return fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Formula/anylint.rb: -------------------------------------------------------------------------------- 1 | class Anylint < Formula 2 | desc "Lint anything by combining the power of Swift & regular expressions" 3 | homepage "https://github.com/FlineDev/AnyLint" 4 | url "https://github.com/FlineDev/AnyLint.git", :tag => "0.11.0", :revision => "3c1bdfc45fe434cb4e3ea7814f49db16f3eeccf2" 5 | head "https://github.com/FlineDev/AnyLint.git" 6 | 7 | depends_on :xcode => ["14.0", :build] 8 | depends_on "swift-sh" 9 | 10 | def install 11 | system "make", "install", "prefix=#{prefix}" 12 | end 13 | 14 | test do 15 | system bin/"anylint", "-v" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/ConfigurationTemplates/ConfigurationTemplate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | protocol ConfigurationTemplate { 5 | static func fileContents() -> String 6 | } 7 | 8 | extension ConfigurationTemplate { 9 | static var commonPrefix: String { 10 | """ 11 | #!\(CLIConstants.swiftShPath) 12 | import AnyLint // @FlineDev 13 | 14 | try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { 15 | 16 | """ 17 | } 18 | 19 | static var commonSuffix: String { 20 | """ 21 | 22 | } 23 | 24 | """ 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/RegexExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | final class RegexExtTests: XCTestCase { 6 | func testInitWithStringLiteral() { 7 | let regex: Regex = #"(?capture[_\-\.]group)\s+\n(.*)"# 8 | XCTAssertEqual(regex.pattern, #"(?capture[_\-\.]group)\s+\n(.*)"#) 9 | } 10 | 11 | func testInitWithDictionaryLiteral() { 12 | let regex: Regex = [ 13 | "name": #"capture[_\-\.]group"#, 14 | "suffix": #"\s+\n.*"#, 15 | ] 16 | XCTAssertEqual(regex.pattern, #"(?capture[_\-\.]group)(?\s+\n.*)"#) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Utility/TestHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A helper class for Unit Testing only. 4 | public final class TestHelper { 5 | /// The console output data. 6 | public typealias ConsoleOutput = (message: String, level: Logger.PrintLevel) 7 | 8 | /// The shared `TestHelper` object. 9 | public static let shared = TestHelper() 10 | 11 | /// Use only in Unit Tests. 12 | public var consoleOutputs: [ConsoleOutput] = [] 13 | 14 | /// Use only in Unit Tests. 15 | public var exitStatus: Logger.ExitStatus? 16 | 17 | /// Deletes all data collected until now. 18 | public func reset() { 19 | consoleOutputs = [] 20 | exitStatus = nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/AnyLintTests.xcbaseline/17124E59-2851-436E-BEA7-3FAEFD815A8B.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | FilesSearchTests 8 | 9 | testPerformanceOfSameSearchOptions() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.00461 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.sourcery/LinuxMain.stencil: -------------------------------------------------------------------------------- 1 | @testable import AnyLintTests 2 | @testable import Utility 3 | import XCTest 4 | 5 | // swiftlint:disable line_length file_length 6 | 7 | {% for type in types.classes|based:"XCTestCase" %} 8 | extension {{ type.name }} { 9 | static var allTests: [(String, ({{ type.name }}) -> () throws -> Void)] = [ 10 | {% 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 %} 11 | {% endfor %}] 12 | } 13 | 14 | {% endfor %} 15 | XCTMain([ 16 | {% for type in types.classes|based:"XCTestCase" %} testCase({{ type.name }}.allTests){% if not forloop.last %},{% endif %} 17 | {% endfor %}]) 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | 3 | prefix ?= /usr/local 4 | bindir ?= $(prefix)/bin 5 | libdir ?= $(prefix)/lib 6 | srcdir = Sources 7 | 8 | REPODIR = $(shell pwd) 9 | BUILDDIR = $(REPODIR)/.build 10 | SOURCES = $(wildcard $(srcdir)/**/*.swift) 11 | 12 | .DEFAULT_GOAL = all 13 | 14 | .PHONY: all 15 | all: anylint 16 | 17 | anylint: $(SOURCES) 18 | @swift build \ 19 | -c release \ 20 | --disable-sandbox \ 21 | --build-path "$(BUILDDIR)" 22 | 23 | .PHONY: install 24 | install: anylint 25 | @install -d "$(bindir)" "$(libdir)" 26 | @install "$(BUILDDIR)/release/anylint" "$(bindir)" 27 | 28 | .PHONY: uninstall 29 | uninstall: 30 | @rm -rf "$(bindir)/anylint" 31 | 32 | .PHONY: clean 33 | distclean: 34 | @rm -f $(BUILDDIR)/release 35 | 36 | .PHONY: clean 37 | clean: distclean 38 | @rm -rf $(BUILDDIR) 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | pull_request: 8 | branches: [main] 9 | 10 | concurrency: 11 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 12 | cancel-in-progress: true 13 | 14 | 15 | jobs: 16 | swiftlint: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout Source 21 | uses: actions/checkout@v3 22 | 23 | - name: Run SwiftLint 24 | uses: norio-nomura/action-swiftlint@3.2.1 25 | with: 26 | args: --strict 27 | 28 | ci: 29 | runs-on: macos-latest 30 | needs: swiftlint 31 | 32 | steps: 33 | - name: Checkout Source 34 | uses: actions/checkout@v3 35 | 36 | - name: Run tests 37 | run: swift test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | # Thumbnails 10 | ._* 11 | 12 | # Files that might appear in the root of a volume 13 | .DocumentRevisions-V100 14 | .fseventsd 15 | .Spotlight-V100 16 | .TemporaryItems 17 | .Trashes 18 | .VolumeIcon.icns 19 | .com.apple.timemachine.donotpresent 20 | 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | # Xcode 29 | ## User settings 30 | xcuserdata/ 31 | 32 | ## Obj-C/Swift specific 33 | *.hmap 34 | 35 | ## App packaging 36 | *.ipa 37 | *.dSYM.zip 38 | *.dSYM 39 | 40 | ## Playgrounds 41 | timeline.xctimeline 42 | playground.xcworkspace 43 | 44 | # Swift Package Manager 45 | Packages/ 46 | Package.pins 47 | Package.resolved 48 | *.xcodeproj 49 | .swiftpm 50 | .build/ 51 | 52 | # AnyLint specific 53 | anylint-test-results.json 54 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/Extensions/ArrayExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | final class ArrayExtTests: XCTestCase { 6 | func testContainsLineAtIndexesMatchingRegex() { 7 | let regex: Regex = #"foo:bar"# 8 | let lines: [String] = ["hello\n foo", "hello\n foo bar", "hello bar", "\nfoo:\nbar", "foo:bar", ":foo:bar"] 9 | 10 | XCTAssertFalse(lines.containsLine(at: [1, 2, 3], matchingRegex: regex)) 11 | XCTAssertFalse(lines.containsLine(at: [-2, -1, 0], matchingRegex: regex)) 12 | XCTAssertFalse(lines.containsLine(at: [-1, 2, 10], matchingRegex: regex)) 13 | XCTAssertFalse(lines.containsLine(at: [3, 2], matchingRegex: regex)) 14 | 15 | XCTAssertTrue(lines.containsLine(at: [-2, 3, 4], matchingRegex: regex)) 16 | XCTAssertTrue(lines.containsLine(at: [5, 6, 7], matchingRegex: regex)) 17 | XCTAssertTrue(lines.containsLine(at: [-2, 4, 10], matchingRegex: regex)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "AnyLint", 6 | platforms: [.macOS(.v10_13)], 7 | products: [ 8 | .library(name: "AnyLint", targets: ["AnyLint"]), 9 | .executable(name: "anylint", targets: ["AnyLintCLI"]), 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.5"), 13 | .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.1"), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "AnyLint", 18 | dependencies: ["Utility"] 19 | ), 20 | .testTarget( 21 | name: "AnyLintTests", 22 | dependencies: ["AnyLint"] 23 | ), 24 | .executableTarget( 25 | name: "AnyLintCLI", 26 | dependencies: ["Rainbow", "SwiftCLI", "Utility"] 27 | ), 28 | .target( 29 | name: "Utility", 30 | dependencies: ["Rainbow"] 31 | ), 32 | .testTarget( 33 | name: "UtilityTests", 34 | dependencies: ["Utility"] 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/AnyLintTests.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | 17124E59-2851-436E-BEA7-3FAEFD815A8B 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 400 13 | cpuCount 14 | 1 15 | cpuKind 16 | 8-Core Intel Core i9 17 | cpuSpeedInMHz 18 | 2300 19 | logicalCPUCoresPerPackage 20 | 16 21 | modelCode 22 | MacBookPro15,1 23 | physicalCPUCoresPerPackage 24 | 8 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | x86_64h 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Globals/ValidateOrFail.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | import Utility 4 | 5 | enum ValidateOrFail { 6 | /// Fails if swift-sh is not installed. 7 | static func swiftShInstalled() { 8 | guard fileManager.fileExists(atPath: CLIConstants.swiftShPath) else { 9 | log.message( 10 | "swift-sh not installed – please try `brew install swift-sh` or follow instructions on https://github.com/mxcl/swift-sh#installation", 11 | level: .error 12 | ) 13 | log.exit(status: .failure) 14 | return // only reachable in unit tests 15 | } 16 | } 17 | 18 | static func configFileExists(at configFilePath: String) throws { 19 | guard fileManager.fileExists(atPath: configFilePath) else { 20 | log.message( 21 | "No configuration file found at \(configFilePath) – consider running `--init` with a template, e.g.`\(CLIConstants.commandName) --init blank`.", 22 | level: .error 23 | ) 24 | log.exit(status: .failure) 25 | return // only reachable in unit tests 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/ViolationTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | import Rainbow 3 | @testable import Utility 4 | import XCTest 5 | 6 | final class ViolationTests: XCTestCase { 7 | override func setUp() { 8 | log = Logger(outputType: .test) 9 | TestHelper.shared.reset() 10 | Statistics.shared.reset() 11 | } 12 | 13 | func testLocationMessage() { 14 | let checkInfo = CheckInfo(id: "demo_check", hint: "Make sure to always check the demo.", severity: .warning) 15 | XCTAssertNil(Violation(checkInfo: checkInfo).locationMessage(pathType: .relative)) 16 | 17 | let fileViolation = Violation(checkInfo: checkInfo, filePath: "Temp/Souces/Hello.swift") 18 | XCTAssertEqual(fileViolation.locationMessage(pathType: .relative), "Temp/Souces/Hello.swift") 19 | 20 | let locationInfoViolation = Violation( 21 | checkInfo: checkInfo, 22 | filePath: "Temp/Souces/World.swift", 23 | locationInfo: String.LocationInfo(line: 5, charInLine: 15) 24 | ) 25 | 26 | XCTAssertEqual(locationInfoViolation.locationMessage(pathType: .relative), "Temp/Souces/World.swift:5:15:") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 FlineDev (alias Cihat Gündüz) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/Extensions/XCTestCaseExt.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | import Foundation 3 | import XCTest 4 | 5 | extension XCTestCase { 6 | typealias TemporaryFile = (subpath: String, contents: String) 7 | 8 | var tempDir: String { "AnyLintTempTests" } 9 | 10 | func withTemporaryFiles(_ temporaryFiles: [TemporaryFile], testCode: ([String]) throws -> Void) { 11 | var filePathsToCheck: [String] = [] 12 | 13 | for tempFile in temporaryFiles { 14 | let tempFileUrl = FileManager.default.currentDirectoryUrl.appendingPathComponent(tempDir).appendingPathComponent(tempFile.subpath) 15 | let tempFileParentDirUrl = tempFileUrl.deletingLastPathComponent() 16 | try? FileManager.default.createDirectory(atPath: tempFileParentDirUrl.path, withIntermediateDirectories: true, attributes: nil) 17 | FileManager.default.createFile(atPath: tempFileUrl.path, contents: tempFile.contents.data(using: .utf8), attributes: nil) 18 | filePathsToCheck.append(tempFileUrl.relativePathFromCurrent) 19 | } 20 | 21 | try? testCode(filePathsToCheck) 22 | 23 | try? FileManager.default.removeItem(atPath: tempDir) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/UtilityTests/LoggerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Utility 2 | import XCTest 3 | 4 | final class LoggerTests: XCTestCase { 5 | override func setUp() { 6 | log = Logger(outputType: .test) 7 | TestHelper.shared.reset() 8 | } 9 | 10 | func testMessage() { 11 | XCTAssert(TestHelper.shared.consoleOutputs.isEmpty) 12 | 13 | log.message("Test", level: .info) 14 | 15 | XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 1) 16 | XCTAssertEqual(TestHelper.shared.consoleOutputs[0].level, .info) 17 | XCTAssertEqual(TestHelper.shared.consoleOutputs[0].message, "Test") 18 | 19 | log.message("Test 2", level: .warning) 20 | 21 | XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 2) 22 | XCTAssertEqual(TestHelper.shared.consoleOutputs[1].level, .warning) 23 | XCTAssertEqual(TestHelper.shared.consoleOutputs[1].message, "Test 2") 24 | 25 | log.message("Test 3", level: .error) 26 | 27 | XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 3) 28 | XCTAssertEqual(TestHelper.shared.consoleOutputs[2].level, .error) 29 | XCTAssertEqual(TestHelper.shared.consoleOutputs[2].message, "Test 3") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/AnyLint/Severity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// Defines the severity of a lint check. 5 | public enum Severity: Int, CaseIterable { 6 | /// Use for checks that are mostly informational and not necessarily problematic. 7 | case info 8 | 9 | /// Use for checks that might potentially be problematic. 10 | case warning 11 | 12 | /// Use for checks that probably are problematic. 13 | case error 14 | 15 | var logLevel: Logger.PrintLevel { 16 | switch self { 17 | case .info: 18 | return .info 19 | 20 | case .warning: 21 | return .warning 22 | 23 | case .error: 24 | return .error 25 | } 26 | } 27 | 28 | static func from(string: String) -> Severity? { 29 | switch string { 30 | case "info", "i": 31 | return .info 32 | 33 | case "warning", "w": 34 | return .warning 35 | 36 | case "error", "e": 37 | return .error 38 | 39 | default: 40 | return nil 41 | } 42 | } 43 | } 44 | 45 | extension Severity: Comparable { 46 | public static func < (lhs: Severity, rhs: Severity) -> Bool { 47 | lhs.rawValue < rhs.rawValue 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/AnyLint/Extensions/StringExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// `Regex` is a swifty regex engine built on top of the NSRegularExpression api. 5 | public typealias Regex = Utility.Regex 6 | 7 | extension String { 8 | /// Info about the exact location of a character in a given file. 9 | public typealias LocationInfo = (line: Int, charInLine: Int) 10 | 11 | /// Returns the location info for a given line index. 12 | public func locationInfo(of index: String.Index) -> LocationInfo { 13 | let prefix = self[startIndex ..< index] 14 | let prefixLines = prefix.components(separatedBy: .newlines) 15 | guard let lastPrefixLine = prefixLines.last else { return (line: 1, charInLine: 1) } 16 | 17 | let charInLine = prefix.last == "\n" ? 1 : lastPrefixLine.count + 1 18 | return (line: prefixLines.count, charInLine: charInLine) 19 | } 20 | 21 | func showNewlines() -> String { 22 | components(separatedBy: .newlines).joined(separator: #"\n"#) 23 | } 24 | 25 | func showWhitespaces() -> String { 26 | components(separatedBy: .whitespaces).joined(separator: "␣") 27 | } 28 | 29 | func showWhitespacesAndNewlines() -> String { 30 | showNewlines().showWhitespaces() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/CheckInfoTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | final class CheckInfoTests: XCTestCase { 6 | override func setUp() { 7 | log = Logger(outputType: .test) 8 | TestHelper.shared.reset() 9 | } 10 | 11 | func testInitWithStringLiteral() { 12 | XCTAssert(TestHelper.shared.consoleOutputs.isEmpty) 13 | 14 | let checkInfo1: CheckInfo = "test1@error: hint1" 15 | XCTAssertEqual(checkInfo1.id, "test1") 16 | XCTAssertEqual(checkInfo1.hint, "hint1") 17 | XCTAssertEqual(checkInfo1.severity, .error) 18 | 19 | let checkInfo2: CheckInfo = "test2@warning: hint2" 20 | XCTAssertEqual(checkInfo2.id, "test2") 21 | XCTAssertEqual(checkInfo2.hint, "hint2") 22 | XCTAssertEqual(checkInfo2.severity, .warning) 23 | 24 | let checkInfo3: CheckInfo = "test3@info: hint3" 25 | XCTAssertEqual(checkInfo3.id, "test3") 26 | XCTAssertEqual(checkInfo3.hint, "hint3") 27 | XCTAssertEqual(checkInfo3.severity, .info) 28 | 29 | let checkInfo4: CheckInfo = "test4: hint4" 30 | XCTAssertEqual(checkInfo4.id, "test4") 31 | XCTAssertEqual(checkInfo4.hint, "hint4") 32 | XCTAssertEqual(checkInfo4.severity, .warning) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/AnyLint/ViolationLocationConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Configuration for the position of the violation marker violations should be reported at. 4 | public struct ViolationLocationConfig { 5 | /// The range to use for pointer reporting. One of `.fullMatch` or `.captureGroup(index:)`. 6 | public enum Range { 7 | /// Uses the full matched range of the Regex. 8 | case fullMatch 9 | 10 | /// Uses the capture group range of the provided index. 11 | case captureGroup(index: Int) 12 | } 13 | 14 | /// The bound to use for pionter reporting. One of `.lower` or `.upper`. 15 | public enum Bound { 16 | /// Uses the lower end of the provided range. 17 | case lower 18 | 19 | /// Uses the upper end of the provided range. 20 | case upper 21 | } 22 | 23 | let range: Range 24 | let bound: Bound 25 | 26 | /// Initializes a new instance with given range and bound. 27 | /// - Parameters: 28 | /// - range: The range to use for pointer reporting. One of `.fullMatch` or `.captureGroup(index:)`. 29 | /// - bound: The bound to use for pionter reporting. One of `.lower` or `.upper`. 30 | public init(range: Range, bound: Bound) { 31 | self.range = range 32 | self.bound = bound 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/AutoCorrectionTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | import XCTest 3 | 4 | final class AutoCorrectionTests: XCTestCase { 5 | func testInitWithDictionaryLiteral() { 6 | let autoCorrection: AutoCorrection = ["before": "Lisence", "after": "License"] 7 | XCTAssertEqual(autoCorrection.before, "Lisence") 8 | XCTAssertEqual(autoCorrection.after, "License") 9 | } 10 | 11 | func testAppliedMessageLines() { 12 | let singleLineAutoCorrection: AutoCorrection = ["before": "Lisence", "after": "License"] 13 | XCTAssertEqual( 14 | singleLineAutoCorrection.appliedMessageLines, 15 | [ 16 | "Autocorrection applied, the diff is: (+ added, - removed)", 17 | "- Lisence", 18 | "+ License", 19 | ] 20 | ) 21 | 22 | let multiLineAutoCorrection: AutoCorrection = [ 23 | "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", 24 | "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", 25 | ] 26 | XCTAssertEqual( 27 | multiLineAutoCorrection.appliedMessageLines, 28 | [ 29 | "Autocorrection applied, the diff is: (+ added, - removed)", 30 | "- [L3] C", 31 | "+ [L5] F1", 32 | "- [L6] F", 33 | "+ [L6] F2", 34 | ] 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Tasks/InitTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | import Utility 4 | 5 | struct InitTask { 6 | enum Template: String, CaseIterable { 7 | case blank 8 | 9 | var configFileContents: String { 10 | switch self { 11 | case .blank: 12 | return BlankTemplate.fileContents() 13 | } 14 | } 15 | } 16 | 17 | let configFilePath: String 18 | let template: Template 19 | } 20 | 21 | extension InitTask: TaskHandler { 22 | func perform() throws { 23 | guard !fileManager.fileExists(atPath: configFilePath) else { 24 | log.message("Configuration file already exists at path '\(configFilePath)'.", level: .error) 25 | log.exit(status: .failure) 26 | return // only reachable in unit tests 27 | } 28 | 29 | ValidateOrFail.swiftShInstalled() 30 | 31 | log.message("Making sure config file directory exists ...", level: .info) 32 | try Task.run(bash: "mkdir -p '\(configFilePath.parentDirectoryPath)'") 33 | 34 | log.message("Creating config file using template '\(template.rawValue)' ...", level: .info) 35 | fileManager.createFile( 36 | atPath: configFilePath, 37 | contents: template.configFileContents.data(using: .utf8), 38 | attributes: nil 39 | ) 40 | 41 | log.message("Making config file executable ...", level: .info) 42 | try Task.run(bash: "chmod +x '\(configFilePath)'") 43 | 44 | log.message("Successfully created config file at \(configFilePath)", level: .success) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/AnyLint/Violation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rainbow 3 | import Utility 4 | 5 | /// A violation found in a check. 6 | public struct Violation { 7 | /// The info about the chack that caused this violation. 8 | public let checkInfo: CheckInfo 9 | 10 | /// The file path the violation is related to. 11 | public let filePath: String? 12 | 13 | /// The matched string that violates the check. 14 | public let matchedString: String? 15 | 16 | /// The info about the exact location of the violation within the file. Will be ignored if no `filePath` specified. 17 | public let locationInfo: String.LocationInfo? 18 | 19 | /// The autocorrection applied to fix this violation. 20 | public let appliedAutoCorrection: AutoCorrection? 21 | 22 | /// Initializes a violation object. 23 | public init( 24 | checkInfo: CheckInfo, 25 | filePath: String? = nil, 26 | matchedString: String? = nil, 27 | locationInfo: String.LocationInfo? = nil, 28 | appliedAutoCorrection: AutoCorrection? = nil 29 | ) { 30 | self.checkInfo = checkInfo 31 | self.filePath = filePath 32 | self.matchedString = matchedString 33 | self.locationInfo = locationInfo 34 | self.appliedAutoCorrection = appliedAutoCorrection 35 | } 36 | 37 | /// Returns a string representation of a violations filled with path and line information if available. 38 | public func locationMessage(pathType: String.PathType) -> String? { 39 | guard let filePath = filePath else { return nil } 40 | guard let locationInfo = locationInfo else { return filePath.path(type: pathType) } 41 | return "\(filePath.path(type: pathType)):\(locationInfo.line):\(locationInfo.charInLine):" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Globals/CLIConstants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CLIConstants { 4 | static let commandName: String = "anylint" 5 | static let defaultConfigFileName: String = "lint.swift" 6 | static let initTemplateCases: String = InitTask.Template.allCases.map { $0.rawValue }.joined(separator: ", ") 7 | static var swiftShPath: String { 8 | switch self.getPlatform() { 9 | case .intel: 10 | return "/usr/local/bin/swift-sh" 11 | 12 | case .appleSilicon: 13 | return "/opt/homebrew/bin/swift-sh" 14 | 15 | case .linux: 16 | return "/home/linuxbrew/.linuxbrew/bin/swift-sh" 17 | } 18 | } 19 | } 20 | 21 | extension CLIConstants { 22 | fileprivate enum Platform { 23 | case intel 24 | case appleSilicon 25 | case linux 26 | } 27 | 28 | fileprivate static func getPlatform() -> Platform { 29 | #if os(Linux) 30 | return .linux 31 | #else 32 | // Source: https://stackoverflow.com/a/69624732 33 | var systemInfo = utsname() 34 | let exitCode = uname(&systemInfo) 35 | 36 | let fallbackPlatform: Platform = .appleSilicon 37 | guard exitCode == EXIT_SUCCESS else { return fallbackPlatform } 38 | 39 | let cpuArchitecture = withUnsafePointer(to: &systemInfo.machine) { unsafePointer in 40 | unsafePointer.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { pointer in 41 | String(cString: pointer) 42 | } 43 | } 44 | 45 | switch cpuArchitecture { 46 | case "x86_64": 47 | return .intel 48 | 49 | case "arm64": 50 | return .appleSilicon 51 | 52 | default: 53 | return fallbackPlatform 54 | } 55 | #endif 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Utility/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Shortcut to access the default `FileManager` within this project. 4 | public let fileManager = FileManager.default 5 | 6 | /// Shortcut to access the `Logger` within this project. 7 | public var log = Logger(outputType: .console) 8 | 9 | /// Constants to reference across the project. 10 | public enum Constants { 11 | /// The current tool version string. Conforms to SemVer 2.0. 12 | public static let currentVersion: String = "0.11.0" 13 | 14 | /// The name of this tool. 15 | public static let toolName: String = "AnyLint" 16 | 17 | /// The debug mode argument for command line pass-through. 18 | public static let debugArgument: String = "debug" 19 | 20 | /// The strict mode argument for command-line pass-through. 21 | public static let strictArgument: String = "strict" 22 | 23 | /// The validate-only mode argument for command-line pass-through. 24 | public static let validateArgument: String = "validate" 25 | 26 | /// The unvalidated mode argument for command-line pass-through. 27 | public static let unvalidatedArgument: String = "unvalidated" 28 | 29 | /// The measure mode to see how long each check took to execute 30 | public static let measureArgument: String = "measure" 31 | 32 | /// The separator indicating that next come regex options. 33 | public static let regexOptionsSeparator: String = #"\"# 34 | 35 | /// Hint that the case insensitive option should be active on a Regex. 36 | public static let caseInsensitiveRegexOption: String = "i" 37 | 38 | /// Hint that the case dot matches newline option should be active on a Regex. 39 | public static let dotMatchesNewlinesRegexOption: String = "m" 40 | 41 | /// The number of newlines required in both before and after of AutoCorrections required to use diff for outputs. 42 | public static let newlinesRequiredForDiffing: Int = 3 43 | } 44 | -------------------------------------------------------------------------------- /Sources/AnyLint/Extensions/FileManagerExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | extension FileManager { 5 | /// Moves a file from one path to another, making sure that all directories are created and no files are overwritten. 6 | public func moveFileSafely(from sourcePath: String, to targetPath: String) throws { 7 | guard fileExists(atPath: sourcePath) else { 8 | log.message("No file found at \(sourcePath) to move.", level: .error) 9 | log.exit(status: .failure) 10 | return // only reachable in unit tests 11 | } 12 | 13 | guard !fileExists(atPath: targetPath) || sourcePath.lowercased() == targetPath.lowercased() else { 14 | log.message("File already exists at target path \(targetPath) – can't move from \(sourcePath).", level: .warning) 15 | return 16 | } 17 | 18 | let targetParentDirectoryPath = targetPath.parentDirectoryPath 19 | if !fileExists(atPath: targetParentDirectoryPath) { 20 | try createDirectory(atPath: targetParentDirectoryPath, withIntermediateDirectories: true, attributes: nil) 21 | } 22 | 23 | guard fileExistsAndIsDirectory(atPath: targetParentDirectoryPath) else { 24 | log.message("Expected \(targetParentDirectoryPath) to be a directory.", level: .error) 25 | log.exit(status: .failure) 26 | return // only reachable in unit tests 27 | } 28 | 29 | if sourcePath.lowercased() == targetPath.lowercased() { 30 | // workaround issues on case insensitive file systems 31 | let temporaryTargetPath = targetPath + UUID().uuidString 32 | try moveItem(atPath: sourcePath, toPath: temporaryTargetPath) 33 | try moveItem(atPath: temporaryTargetPath, toPath: targetPath) 34 | } else { 35 | try moveItem(atPath: sourcePath, toPath: targetPath) 36 | } 37 | 38 | FilesSearch.shared.invalidateCache() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Tasks/LintTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | import Utility 4 | 5 | struct LintTask { 6 | let configFilePath: String 7 | let logDebugLevel: Bool 8 | let failOnWarnings: Bool 9 | let validateOnly: Bool 10 | let unvalidated: Bool 11 | let measure: Bool 12 | } 13 | 14 | extension LintTask: TaskHandler { 15 | enum LintError: Error { 16 | case configFileFailed 17 | } 18 | 19 | /// - Throws: `LintError.configFileFailed` if running a configuration file fails 20 | func perform() throws { 21 | try ValidateOrFail.configFileExists(at: configFilePath) 22 | 23 | if !fileManager.isExecutableFile(atPath: configFilePath) { 24 | try Task.run(bash: "chmod +x '\(configFilePath)'") 25 | } 26 | 27 | ValidateOrFail.swiftShInstalled() 28 | 29 | do { 30 | log.message("Start linting using config file at \(configFilePath) ...", level: .info) 31 | 32 | var command = "\(configFilePath.absolutePath) \(log.outputType.rawValue)" 33 | 34 | if logDebugLevel { 35 | command += " \(Constants.debugArgument)" 36 | } 37 | 38 | if failOnWarnings { 39 | command += " \(Constants.strictArgument)" 40 | } 41 | 42 | if validateOnly { 43 | command += " \(Constants.validateArgument)" 44 | } 45 | 46 | if unvalidated { 47 | command += " \(Constants.unvalidatedArgument)" 48 | } 49 | 50 | if measure { 51 | command += " \(Constants.measureArgument)" 52 | } 53 | 54 | try Task.run(bash: command) 55 | log.message("Linting successful using config file at \(configFilePath). Congrats! 🎉", level: .success) 56 | } catch is RunError { 57 | if log.outputType != .xcode { 58 | log.message("Linting failed using config file at \(configFilePath).", level: .error) 59 | } 60 | 61 | throw LintError.configFileFailed 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Utility/Extensions/StringExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | /// The type of a given file path. 5 | public enum PathType { 6 | /// The relative path. 7 | case relative 8 | 9 | /// The absolute path. 10 | case absolute 11 | } 12 | 13 | /// Returns the absolute path for a path given relative to the current directory. 14 | public var absolutePath: String { 15 | guard !self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } 16 | return fileManager.currentDirectoryUrl.appendingPathComponent(self).path 17 | } 18 | 19 | /// Returns the relative path for a path given relative to the current directory. 20 | public var relativePath: String { 21 | guard self.starts(with: fileManager.currentDirectoryUrl.path) else { return self } 22 | return replacingOccurrences(of: fileManager.currentDirectoryUrl.path, with: "") 23 | } 24 | 25 | /// Returns the parent directory path. 26 | public var parentDirectoryPath: String { 27 | let url = URL(fileURLWithPath: self) 28 | guard url.pathComponents.count > 1 else { return fileManager.currentDirectoryPath } 29 | return url.deletingLastPathComponent().absoluteString 30 | } 31 | 32 | /// Returns the path with the given type related to the current directory. 33 | public func path(type: PathType) -> String { 34 | switch type { 35 | case .absolute: 36 | return absolutePath 37 | 38 | case .relative: 39 | return relativePath 40 | } 41 | } 42 | 43 | /// Returns the path with a components appended at it. 44 | public func appendingPathComponent(_ pathComponent: String) -> String { 45 | guard let pathUrl = URL(string: self) else { 46 | log.message("Could not convert path '\(self)' to type URL.", level: .error) 47 | log.exit(status: .failure) 48 | return "" // only reachable in unit tests 49 | } 50 | 51 | return pathUrl.appendingPathComponent(pathComponent).absoluteString 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/UtilityTests/Extensions/RegexExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Utility 2 | import XCTest 3 | 4 | final class RegexExtTests: XCTestCase { 5 | func testStringLiteralInit() { 6 | let regex: Regex = #".*"# 7 | XCTAssertEqual(regex.description, #"/.*/"#) 8 | } 9 | 10 | func testStringLiteralInitWithOptions() { 11 | let regexI: Regex = #".*\i"# 12 | XCTAssertEqual(regexI.description, #"/.*/i"#) 13 | 14 | let regexM: Regex = #".*\m"# 15 | XCTAssertEqual(regexM.description, #"/.*/m"#) 16 | 17 | let regexIM: Regex = #".*\im"# 18 | XCTAssertEqual(regexIM.description, #"/.*/im"#) 19 | 20 | let regexMI: Regex = #".*\mi"# 21 | XCTAssertEqual(regexMI.description, #"/.*/im"#) 22 | } 23 | 24 | func testDictionaryLiteralInit() { 25 | let regex: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#] 26 | XCTAssertEqual(regex.description, #"/(?[a-z]+)(?\d+\.?\d*)/"#) 27 | } 28 | 29 | func testDictionaryLiteralInitWithOptions() { 30 | let regexI: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "i"] 31 | XCTAssertEqual(regexI.description, #"/(?[a-z]+)(?\d+\.?\d*)/i"#) 32 | 33 | let regexM: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "m"] 34 | XCTAssertEqual(regexM.description, #"/(?[a-z]+)(?\d+\.?\d*)/m"#) 35 | 36 | let regexMI: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "mi"] 37 | XCTAssertEqual(regexMI.description, #"/(?[a-z]+)(?\d+\.?\d*)/im"#) 38 | 39 | let regexIM: Regex = ["chars": #"[a-z]+"#, "num": #"\d+\.?\d*"#, #"\"#: "im"] 40 | XCTAssertEqual(regexIM.description, #"/(?[a-z]+)(?\d+\.?\d*)/im"#) 41 | } 42 | 43 | func testReplacingMatchesInInputWithTemplate() { 44 | let regexTrailing: Regex = #"(?<=\n)([-–] .*[^ ])( {0,1}| {3,})\n"# 45 | let text: String = "\n- Sample Text.\n" 46 | 47 | XCTAssertEqual( 48 | regexTrailing.replacingMatches(in: text, with: "$1 \n"), 49 | "\n- Sample Text. \n" 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/AnyLint/Checkers/FilePathsChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | struct FilePathsChecker { 5 | let checkInfo: CheckInfo 6 | let regex: Regex 7 | let filePathsToCheck: [String] 8 | let autoCorrectReplacement: String? 9 | let violateIfNoMatchesFound: Bool 10 | } 11 | 12 | extension FilePathsChecker: Checker { 13 | func performCheck() throws -> [Violation] { 14 | var violations: [Violation] = [] 15 | 16 | if violateIfNoMatchesFound { 17 | let matchingFilePathsCount = filePathsToCheck.filter { regex.matches($0) }.count 18 | if matchingFilePathsCount <= 0 { 19 | log.message("Reporting violation for \(checkInfo) as no matching file was found ...", level: .debug) 20 | violations.append( 21 | Violation(checkInfo: checkInfo, filePath: nil, locationInfo: nil, appliedAutoCorrection: nil) 22 | ) 23 | } 24 | } else { 25 | for filePath in filePathsToCheck where regex.matches(filePath) { 26 | log.message("Found violating match for \(checkInfo) ...", level: .debug) 27 | 28 | let appliedAutoCorrection: AutoCorrection? = try { 29 | guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } 30 | 31 | let newFilePath = regex.replaceAllCaptures(in: filePath, with: autoCorrectReplacement) 32 | try fileManager.moveFileSafely(from: filePath, to: newFilePath) 33 | 34 | return AutoCorrection(before: filePath, after: newFilePath) 35 | }() 36 | 37 | if appliedAutoCorrection != nil { 38 | log.message("Applied autocorrection for last match ...", level: .debug) 39 | } 40 | 41 | log.message("Reporting violation for \(checkInfo) in file \(filePath) ...", level: .debug) 42 | violations.append( 43 | Violation(checkInfo: checkInfo, filePath: filePath, locationInfo: nil, appliedAutoCorrection: appliedAutoCorrection) 44 | ) 45 | } 46 | 47 | Statistics.shared.checkedFiles(at: filePathsToCheck) 48 | } 49 | 50 | return violations 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/FilesSearchTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | // swiftlint:disable force_try 6 | 7 | final class FilesSearchTests: XCTestCase { 8 | override func setUp() { 9 | log = Logger(outputType: .test) 10 | TestHelper.shared.reset() 11 | } 12 | 13 | func testAllFilesWithinPath() { 14 | withTemporaryFiles( 15 | [ 16 | (subpath: "Sources/Hello.swift", contents: ""), 17 | (subpath: "Sources/World.swift", contents: ""), 18 | (subpath: "Sources/.hidden_file", contents: ""), 19 | (subpath: "Sources/.hidden_dir/unhidden_file", contents: ""), 20 | ] 21 | ) { _ in 22 | let includeFilterFilePaths = FilesSearch.shared.allFiles( 23 | within: FileManager.default.currentDirectoryPath, 24 | includeFilters: [try Regex("\(tempDir)/.*")], 25 | excludeFilters: [] 26 | ).sorted() 27 | XCTAssertEqual(includeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift", "\(tempDir)/Sources/World.swift"]) 28 | 29 | let excludeFilterFilePaths = FilesSearch.shared.allFiles( 30 | within: FileManager.default.currentDirectoryPath, 31 | includeFilters: [try Regex("\(tempDir)/.*")], 32 | excludeFilters: ["World"] 33 | ) 34 | XCTAssertEqual(excludeFilterFilePaths, ["\(tempDir)/Sources/Hello.swift"]) 35 | } 36 | } 37 | 38 | func testPerformanceOfSameSearchOptions() { 39 | let swiftSourcesFilePaths = (0 ... 800).map { (subpath: "Sources/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } 40 | let testsFilePaths = (0 ... 400).map { (subpath: "Tests/Foo\($0).swift", contents: "Lorem ipsum\ndolor sit amet\n") } 41 | let storyboardSourcesFilePaths = (0 ... 300).map { (subpath: "Sources/Foo\($0).storyboard", contents: "Lorem ipsum\ndolor sit amet\n") } 42 | 43 | withTemporaryFiles(swiftSourcesFilePaths + testsFilePaths + storyboardSourcesFilePaths) { _ in 44 | let fileSearchCode: () -> [String] = { 45 | FilesSearch.shared.allFiles( 46 | within: FileManager.default.currentDirectoryPath, 47 | includeFilters: [try! Regex(#"\#(self.tempDir)/Sources/Foo.*"#)], 48 | excludeFilters: [try! Regex(#"\#(self.tempDir)/.*\.storyboard"#)] 49 | ) 50 | } 51 | 52 | // first run 53 | XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) 54 | 55 | measure { 56 | // subsequent runs (should be much faster) 57 | XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) 58 | XCTAssertEqual(Set(fileSearchCode()), Set(swiftSourcesFilePaths.map { "\(tempDir)/\($0.subpath)" })) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/Checkers/FilePathsCheckerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | final class FilePathsCheckerTests: XCTestCase { 6 | override func setUp() { 7 | log = Logger(outputType: .test) 8 | TestHelper.shared.reset() 9 | } 10 | 11 | func testPerformCheck() { 12 | withTemporaryFiles( 13 | [ 14 | (subpath: "Sources/Hello.swift", contents: ""), 15 | (subpath: "Sources/World.swift", contents: ""), 16 | ] 17 | ) { filePathsToCheck in 18 | let violations = try sayHelloChecker(filePathsToCheck: filePathsToCheck).performCheck() 19 | XCTAssertEqual(violations.count, 0) 20 | } 21 | 22 | withTemporaryFiles([(subpath: "Sources/World.swift", contents: "")]) { filePathsToCheck in 23 | let violations = try sayHelloChecker(filePathsToCheck: filePathsToCheck).performCheck() 24 | 25 | XCTAssertEqual(violations.count, 1) 26 | 27 | XCTAssertEqual(violations[0].checkInfo, sayHelloCheck()) 28 | XCTAssertNil(violations[0].filePath) 29 | XCTAssertNil(violations[0].locationInfo) 30 | XCTAssertNil(violations[0].locationInfo) 31 | } 32 | 33 | withTemporaryFiles( 34 | [ 35 | (subpath: "Sources/Hello.swift", contents: ""), 36 | (subpath: "Sources/World.swift", contents: ""), 37 | ] 38 | ) { filePathsToCheck in 39 | let violations = try noWorldChecker(filePathsToCheck: filePathsToCheck).performCheck() 40 | 41 | XCTAssertEqual(violations.count, 1) 42 | 43 | XCTAssertEqual(violations[0].checkInfo, noWorldCheck()) 44 | XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/World.swift") 45 | XCTAssertNil(violations[0].locationInfo) 46 | XCTAssertNil(violations[0].locationInfo) 47 | } 48 | } 49 | 50 | private func sayHelloChecker(filePathsToCheck: [String]) -> FilePathsChecker { 51 | FilePathsChecker( 52 | checkInfo: sayHelloCheck(), 53 | regex: #".*Hello\.swift"#, 54 | filePathsToCheck: filePathsToCheck, 55 | autoCorrectReplacement: nil, 56 | violateIfNoMatchesFound: true 57 | ) 58 | } 59 | 60 | private func sayHelloCheck() -> CheckInfo { 61 | CheckInfo(id: "say_hello", hint: "Should always say hello.", severity: .info) 62 | } 63 | 64 | private func noWorldChecker(filePathsToCheck: [String]) -> FilePathsChecker { 65 | FilePathsChecker( 66 | checkInfo: noWorldCheck(), 67 | regex: #".*World\.swift"#, 68 | filePathsToCheck: filePathsToCheck, 69 | autoCorrectReplacement: nil, 70 | violateIfNoMatchesFound: false 71 | ) 72 | } 73 | 74 | private func noWorldCheck() -> CheckInfo { 75 | CheckInfo(id: "no_world", hint: "Do not include the global world, be more specific instead.", severity: .error) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/AnyLint/CheckInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// Provides some basic information needed in each lint check. 5 | public struct CheckInfo { 6 | /// The identifier of the check defined here. Can be used when defining exceptions within files for specific lint checks. 7 | public let id: String 8 | 9 | /// 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`). 10 | public let hint: String 11 | 12 | /// The severity level for the report in case the check fails. 13 | public let severity: Severity 14 | 15 | /// Initializes a new info object for the lint check. 16 | public init(id: String, hint: String, severity: Severity = .warning) { 17 | self.id = id 18 | self.hint = hint 19 | self.severity = severity 20 | } 21 | } 22 | 23 | extension CheckInfo: Hashable { 24 | public func hash(into hasher: inout Hasher) { 25 | hasher.combine(id) 26 | } 27 | } 28 | 29 | extension CheckInfo: CustomStringConvertible { 30 | public var description: String { 31 | "check '\(id)'" 32 | } 33 | } 34 | 35 | extension CheckInfo: ExpressibleByStringLiteral { 36 | public init(stringLiteral value: String) { 37 | let customSeverityRegex: Regex = [ 38 | "id": #"^[^@:]+"#, 39 | "severitySeparator": #"@"#, 40 | "severity": #"[^:]+"#, 41 | "hintSeparator": #": ?"#, 42 | "hint": #".*$"#, 43 | ] 44 | 45 | if let customSeverityMatch = customSeverityRegex.firstMatch(in: value) { 46 | let id = customSeverityMatch.captures[0]! 47 | let severityString = customSeverityMatch.captures[2]! 48 | let hint = customSeverityMatch.captures[4]! 49 | 50 | guard let severity = Severity.from(string: severityString) else { 51 | log.message("Specified severity '\(severityString)' for check '\(id)' unknown. Use one of [error, warning, info].", level: .error) 52 | log.exit(status: .failure) 53 | exit(EXIT_FAILURE) // only reachable in unit tests 54 | } 55 | 56 | self = CheckInfo(id: id, hint: hint, severity: severity) 57 | } else { 58 | let defaultSeverityRegex: Regex = [ 59 | "id": #"^[^@:]+"#, 60 | "hintSeparator": #": ?"#, 61 | "hint": #".*$"#, 62 | ] 63 | 64 | guard let defaultSeverityMatch = defaultSeverityRegex.firstMatch(in: value) else { 65 | log.message( 66 | "Could not convert String literal '\(value)' to type CheckInfo. Please check the structure to be: (@): ", 67 | level: .error 68 | ) 69 | log.exit(status: .failure) 70 | exit(EXIT_FAILURE) // only reachable in unit tests 71 | } 72 | 73 | let id = defaultSeverityMatch.captures[0]! 74 | let hint = defaultSeverityMatch.captures[2]! 75 | 76 | self = CheckInfo(id: id, hint: hint) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/ConfigurationTemplates/BlankTemplate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | // swiftlint:disable function_body_length indentation_width 5 | 6 | enum BlankTemplate: ConfigurationTemplate { 7 | static func fileContents() -> String { 8 | commonPrefix + #""" 9 | // MARK: - Variables 10 | let readmeFile: Regex = #"^README\.md$"# 11 | 12 | // MARK: - Checks 13 | // MARK: Readme 14 | try Lint.checkFilePaths( 15 | checkInfo: "Readme: Each project should have a README.md file, explaining how to use or contribute to the project.", 16 | regex: readmeFile, 17 | matchingExamples: ["README.md"], 18 | nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], 19 | violateIfNoMatchesFound: true 20 | ) 21 | 22 | // MARK: ReadmePath 23 | try Lint.checkFilePaths( 24 | checkInfo: "ReadmePath: The README file should be named exactly `README.md`.", 25 | regex: #"^(.*/)?([Rr][Ee][Aa][Dd][Mm][Ee]\.markdown|readme\.md|Readme\.md|ReadMe\.md)$"#, 26 | matchingExamples: ["README.markdown", "readme.md", "ReadMe.md"], 27 | nonMatchingExamples: ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "api/help.md"], 28 | autoCorrectReplacement: "$1README.md", 29 | autoCorrectExamples: [ 30 | ["before": "api/readme.md", "after": "api/README.md"], 31 | ["before": "ReadMe.md", "after": "README.md"], 32 | ["before": "README.markdown", "after": "README.md"], 33 | ] 34 | ) 35 | 36 | // MARK: ReadmeTopLevelTitle 37 | try Lint.checkFileContents( 38 | checkInfo: "ReadmeTopLevelTitle: The README.md file should only contain a single top level title.", 39 | regex: #"(^|\n)#[^#](.*\n)*\n#[^#]"#, 40 | matchingExamples: [ 41 | """ 42 | # Title 43 | ## Subtitle 44 | Lorem ipsum 45 | 46 | # Other Title 47 | ## Other Subtitle 48 | """, 49 | ], 50 | nonMatchingExamples: [ 51 | """ 52 | # Title 53 | ## Subtitle 54 | Lorem ipsum #1 and # 2. 55 | 56 | ## Other Subtitle 57 | ### Other Subsubtitle 58 | """, 59 | ], 60 | includeFilters: [readmeFile] 61 | ) 62 | 63 | // MARK: ReadmeTypoLicense 64 | try Lint.checkFileContents( 65 | checkInfo: "ReadmeTypoLicense: Misspelled word 'license'.", 66 | regex: #"([\s#]L|l)isence([\s\.,:;])"#, 67 | matchingExamples: [" lisence:", "## Lisence\n"], 68 | nonMatchingExamples: [" license:", "## License\n"], 69 | includeFilters: [readmeFile], 70 | autoCorrectReplacement: "$1icense$2", 71 | autoCorrectExamples: [ 72 | ["before": " lisence:", "after": " license:"], 73 | ["before": "## Lisence\n", "after": "## License\n"], 74 | ] 75 | ) 76 | """# + commonSuffix 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Basic Configuration 2 | opt_in_rules: 3 | - anyobject_protocol 4 | - array_init 5 | - attributes 6 | - closure_end_indentation 7 | - closure_spacing 8 | - collection_alignment 9 | - conditional_returns_on_newline 10 | - contains_over_filter_count 11 | - contains_over_filter_is_empty 12 | - contains_over_first_not_nil 13 | - contains_over_range_nil_comparison 14 | - convenience_type 15 | - empty_collection_literal 16 | - empty_count 17 | - empty_string 18 | - empty_xctest_method 19 | - explicit_init 20 | - explicit_type_interface 21 | - fallthrough 22 | - fatal_error_message 23 | - file_name 24 | - file_name_no_space 25 | - file_types_order 26 | - first_where 27 | - flatmap_over_map_reduce 28 | - function_default_parameter_at_end 29 | - identical_operands 30 | - implicit_return 31 | - implicitly_unwrapped_optional 32 | - indentation_width 33 | - joined_default_parameter 34 | - last_where 35 | - legacy_multiple 36 | - legacy_random 37 | - literal_expression_end_indentation 38 | - lower_acl_than_parent 39 | - missing_docs 40 | - modifier_order 41 | - multiline_arguments 42 | - multiline_arguments_brackets 43 | - multiline_literal_brackets 44 | - multiline_parameters 45 | - multiline_parameters_brackets 46 | - nslocalizedstring_key 47 | - number_separator 48 | - object_literal 49 | - operator_usage_whitespace 50 | - optional_enum_case_matching 51 | - override_in_extension 52 | - pattern_matching_keywords 53 | - prefer_self_type_over_type_of_self 54 | - private_action 55 | - private_outlet 56 | - prohibited_super_call 57 | - reduce_into 58 | - redundant_nil_coalescing 59 | - single_test_class 60 | - sorted_first_last 61 | - sorted_imports 62 | - static_operator 63 | - strong_iboutlet 64 | - switch_case_on_newline 65 | - toggle_bool 66 | - trailing_closure 67 | - type_contents_order 68 | - unavailable_function 69 | - unneeded_parentheses_in_closure_argument 70 | - untyped_error_in_catch 71 | - unused_declaration 72 | - unused_import 73 | - vertical_parameter_alignment_on_call 74 | - vertical_whitespace_between_cases 75 | - vertical_whitespace_closing_braces 76 | - vertical_whitespace_opening_braces 77 | - xct_specific_matcher 78 | - yoda_condition 79 | 80 | included: 81 | - Sources 82 | - Tests 83 | 84 | excluded: 85 | - Tests/LinuxMain.swift 86 | 87 | disabled_rules: 88 | - blanket_disable_command 89 | - cyclomatic_complexity 90 | - todo 91 | 92 | 93 | # Rule Configurations 94 | conditional_returns_on_newline: 95 | if_only: true 96 | 97 | explicit_type_interface: 98 | allow_redundancy: true 99 | excluded: 100 | - local 101 | 102 | file_name: 103 | suffix_pattern: "Ext" 104 | 105 | identifier_name: 106 | max_length: 60 107 | excluded: 108 | - id 109 | - db 110 | - to 111 | 112 | indentation_width: 113 | indentation_width: 3 114 | 115 | line_length: 116 | warning: 160 117 | ignores_comments: true 118 | 119 | nesting: 120 | type_level: 3 121 | 122 | trailing_comma: 123 | mandatory_comma: true 124 | 125 | trailing_whitespace: 126 | ignores_comments: false 127 | -------------------------------------------------------------------------------- /Sources/AnyLint/AutoCorrection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// Information about an autocorrection. 5 | public struct AutoCorrection { 6 | /// The matching text before applying the autocorrection. 7 | public let before: String 8 | 9 | /// The matching text after applying the autocorrection. 10 | public let after: String 11 | 12 | var appliedMessageLines: [String] { 13 | if useDiffOutput, #available(OSX 10.15, *) { 14 | var lines: [String] = ["Autocorrection applied, the diff is: (+ added, - removed)"] 15 | 16 | let beforeLines = before.components(separatedBy: .newlines) 17 | let afterLines = after.components(separatedBy: .newlines) 18 | 19 | for difference in afterLines.difference(from: beforeLines).sorted() { 20 | switch difference { 21 | case let .insert(offset, element, _): 22 | lines.append("+ [L\(offset + 1)] \(element)".green) 23 | 24 | case let .remove(offset, element, _): 25 | lines.append("- [L\(offset + 1)] \(element)".red) 26 | } 27 | } 28 | 29 | return lines 30 | } else { 31 | return [ 32 | "Autocorrection applied, the diff is: (+ added, - removed)", 33 | "- \(before.showWhitespacesAndNewlines())".red, 34 | "+ \(after.showWhitespacesAndNewlines())".green, 35 | ] 36 | } 37 | } 38 | 39 | var useDiffOutput: Bool { 40 | before.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing || 41 | after.components(separatedBy: .newlines).count >= Constants.newlinesRequiredForDiffing 42 | } 43 | 44 | /// Initializes an autocorrection. 45 | public init(before: String, after: String) { 46 | self.before = before 47 | self.after = after 48 | } 49 | } 50 | 51 | extension AutoCorrection: ExpressibleByDictionaryLiteral { 52 | public init(dictionaryLiteral elements: (String, String)...) { 53 | guard 54 | let before = elements.first(where: { $0.0 == "before" })?.1, 55 | let after = elements.first(where: { $0.0 == "after" })?.1 56 | else { 57 | log.message("Failed to convert Dictionary literal '\(elements)' to type AutoCorrection.", level: .error) 58 | log.exit(status: .failure) 59 | exit(EXIT_FAILURE) // only reachable in unit tests 60 | } 61 | 62 | self = AutoCorrection(before: before, after: after) 63 | } 64 | } 65 | 66 | // TODO: make the autocorrection diff sorted by line number 67 | @available(OSX 10.15, *) 68 | extension CollectionDifference.Change: Comparable where ChangeElement == String { 69 | public static func < (lhs: Self, rhs: Self) -> Bool { 70 | switch (lhs, rhs) { 71 | case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): 72 | return leftOffset < rightOffset 73 | 74 | case let (.remove(leftOffset, _, _), .insert(rightOffset, _, _)): 75 | return leftOffset < rightOffset || true 76 | 77 | case let (.insert(leftOffset, _, _), .remove(rightOffset, _, _)): 78 | return leftOffset < rightOffset || false 79 | } 80 | } 81 | 82 | public static func == (lhs: Self, rhs: Self) -> Bool { 83 | switch (lhs, rhs) { 84 | case let (.remove(leftOffset, _, _), .remove(rightOffset, _, _)), let (.insert(leftOffset, _, _), .insert(rightOffset, _, _)): 85 | return leftOffset == rightOffset 86 | 87 | case (.remove, .insert), (.insert, .remove): 88 | return false 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | // Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery 2 | // DO NOT EDIT 3 | @testable import AnyLintTests 4 | @testable import Utility 5 | import XCTest 6 | 7 | // swiftlint:disable line_length file_length 8 | 9 | extension ArrayExtTests { 10 | static var allTests: [(String, (ArrayExtTests) -> () throws -> Void)] = [ 11 | ("testContainsLineAtIndexesMatchingRegex", testContainsLineAtIndexesMatchingRegex) 12 | ] 13 | } 14 | 15 | extension AutoCorrectionTests { 16 | static var allTests: [(String, (AutoCorrectionTests) -> () throws -> Void)] = [ 17 | ("testInitWithDictionaryLiteral", testInitWithDictionaryLiteral), 18 | ("testAppliedMessageLines", testAppliedMessageLines) 19 | ] 20 | } 21 | 22 | extension CheckInfoTests { 23 | static var allTests: [(String, (CheckInfoTests) -> () throws -> Void)] = [ 24 | ("testInitWithStringLiteral", testInitWithStringLiteral) 25 | ] 26 | } 27 | 28 | extension FileContentsCheckerTests { 29 | static var allTests: [(String, (FileContentsCheckerTests) -> () throws -> Void)] = [ 30 | ("testPerformCheck", testPerformCheck), 31 | ("testSkipInFile", testSkipInFile), 32 | ("testSkipHere", testSkipHere), 33 | ("testSkipIfEqualsToAutocorrectReplacement", testSkipIfEqualsToAutocorrectReplacement), 34 | ("testRepeatIfAutoCorrected", testRepeatIfAutoCorrected) 35 | ] 36 | } 37 | 38 | extension FilePathsCheckerTests { 39 | static var allTests: [(String, (FilePathsCheckerTests) -> () throws -> Void)] = [ 40 | ("testPerformCheck", testPerformCheck) 41 | ] 42 | } 43 | 44 | extension FilesSearchTests { 45 | static var allTests: [(String, (FilesSearchTests) -> () throws -> Void)] = [ 46 | ("testAllFilesWithinPath", testAllFilesWithinPath), 47 | ("testPerformanceOfSameSearchOptions", testPerformanceOfSameSearchOptions) 48 | ] 49 | } 50 | 51 | extension LintTests { 52 | static var allTests: [(String, (LintTests) -> () throws -> Void)] = [ 53 | ("testValidateRegexMatchesForEach", testValidateRegexMatchesForEach), 54 | ("testValidateRegexDoesNotMatchAny", testValidateRegexDoesNotMatchAny), 55 | ("testValidateAutocorrectsAllExamplesWithAnonymousGroups", testValidateAutocorrectsAllExamplesWithAnonymousGroups), 56 | ("testValidateAutocorrectsAllExamplesWithNamedGroups", testValidateAutocorrectsAllExamplesWithNamedGroups) 57 | ] 58 | } 59 | 60 | extension RegexExtTests { 61 | static var allTests: [(String, (RegexExtTests) -> () throws -> Void)] = [ 62 | ("testInitWithStringLiteral", testInitWithStringLiteral), 63 | ("testInitWithDictionaryLiteral", testInitWithDictionaryLiteral) 64 | ] 65 | } 66 | 67 | extension StatisticsTests { 68 | static var allTests: [(String, (StatisticsTests) -> () throws -> Void)] = [ 69 | ("testFoundViolationsInCheck", testFoundViolationsInCheck), 70 | ("testLogSummary", testLogSummary) 71 | ] 72 | } 73 | 74 | extension ViolationTests { 75 | static var allTests: [(String, (ViolationTests) -> () throws -> Void)] = [ 76 | ("testLocationMessage", testLocationMessage) 77 | ] 78 | } 79 | 80 | XCTMain([ 81 | testCase(ArrayExtTests.allTests), 82 | testCase(AutoCorrectionTests.allTests), 83 | testCase(CheckInfoTests.allTests), 84 | testCase(FileContentsCheckerTests.allTests), 85 | testCase(FilePathsCheckerTests.allTests), 86 | testCase(FilesSearchTests.allTests), 87 | testCase(LintTests.allTests), 88 | testCase(RegexExtTests.allTests), 89 | testCase(StatisticsTests.allTests), 90 | testCase(ViolationTests.allTests) 91 | ]) 92 | -------------------------------------------------------------------------------- /Sources/AnyLintCLI/Commands/SingleCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftCLI 3 | import Utility 4 | 5 | class SingleCommand: Command { 6 | // MARK: - Basics 7 | var name: String = CLIConstants.commandName 8 | var shortDescription: String = "Lint anything by combining the power of Swift & regular expressions." 9 | 10 | // MARK: - Subcommands 11 | @Flag("-v", "--version", description: "Prints the current tool version") 12 | var version: Bool 13 | 14 | @Flag("-x", "--xcode", description: "Prints warnings & errors in a format to be reported right within Xcodes left sidebar") 15 | var xcode: Bool 16 | 17 | @Flag("-d", "--debug", description: "Logs much more detailed information about what AnyLint is doing for debugging purposes") 18 | var debug: Bool 19 | 20 | @Flag("-s", "--strict", description: "Fails on warnings as well - by default, the command only fails on errors)") 21 | var strict: Bool 22 | 23 | @Flag("-l", "--validate", description: "Runs only validations for `matchingExamples`, `nonMatchingExamples` and `autoCorrectExamples`.") 24 | var validate: Bool 25 | 26 | @Flag( 27 | "-u", 28 | "--unvalidated", 29 | description: "Runs the checks without validating their correctness. Only use for faster subsequent runs after a validated run succeeded." 30 | ) 31 | var unvalidated: Bool 32 | 33 | @Flag("-m", "--measure", description: "Prints the time it took to execute each check for performance optimizations") 34 | var measure: Bool 35 | 36 | @Key("-i", "--init", description: "Configure AnyLint with a default template. Has to be one of: [\(CLIConstants.initTemplateCases)]") 37 | var initTemplateName: String? 38 | 39 | // MARK: - Options 40 | @VariadicKey("-p", "--path", description: "Provide a custom path to the config file (multiple usage supported)") 41 | var customPaths: [String] 42 | 43 | // MARK: - Execution 44 | func execute() throws { 45 | if xcode { 46 | log = Logger(outputType: .xcode) 47 | } 48 | 49 | log.logDebugLevel = debug 50 | 51 | // version subcommand 52 | if version { 53 | try VersionTask().perform() 54 | log.exit(status: .success) 55 | } 56 | 57 | let configurationPaths = customPaths.isEmpty 58 | ? [fileManager.currentDirectoryPath.appendingPathComponent(CLIConstants.defaultConfigFileName)] 59 | : customPaths 60 | 61 | // init subcommand 62 | if let initTemplateName = initTemplateName { 63 | guard let initTemplate = InitTask.Template(rawValue: initTemplateName) else { 64 | log.message("Unknown default template '\(initTemplateName)' – use one of: [\(CLIConstants.initTemplateCases)]", level: .error) 65 | log.exit(status: .failure) 66 | return // only reachable in unit tests 67 | } 68 | 69 | for configPath in configurationPaths { 70 | try InitTask(configFilePath: configPath, template: initTemplate).perform() 71 | } 72 | log.exit(status: .success) 73 | } 74 | 75 | // lint main command 76 | var anyConfigFileFailed = false 77 | for configPath in configurationPaths { 78 | do { 79 | try LintTask( 80 | configFilePath: configPath, 81 | logDebugLevel: self.debug, 82 | failOnWarnings: self.strict, 83 | validateOnly: self.validate, 84 | unvalidated: self.unvalidated, 85 | measure: self.measure 86 | ).perform() 87 | } catch LintTask.LintError.configFileFailed { 88 | anyConfigFileFailed = true 89 | } 90 | } 91 | exit(anyConfigFileFailed ? EXIT_FAILURE : EXIT_SUCCESS) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Utility/Extensions/RegexExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Regex: ExpressibleByStringLiteral { 4 | public init(stringLiteral value: String) { 5 | var pattern = value 6 | let options: Options = { 7 | if 8 | value.hasSuffix(Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption + Constants.dotMatchesNewlinesRegexOption) 9 | || value.hasSuffix(Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption + Constants.caseInsensitiveRegexOption) 10 | { 11 | pattern.removeLast((Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption + Constants.caseInsensitiveRegexOption).count) 12 | return Regex.defaultOptions.union([.ignoreCase, .dotMatchesLineSeparators]) 13 | } else if value.hasSuffix(Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption) { 14 | pattern.removeLast((Constants.regexOptionsSeparator + Constants.caseInsensitiveRegexOption).count) 15 | return Regex.defaultOptions.union([.ignoreCase]) 16 | } else if value.hasSuffix(Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption) { 17 | pattern.removeLast((Constants.regexOptionsSeparator + Constants.dotMatchesNewlinesRegexOption).count) 18 | return Regex.defaultOptions.union([.dotMatchesLineSeparators]) 19 | } else { 20 | return Regex.defaultOptions 21 | } 22 | }() 23 | 24 | do { 25 | self = try Regex(pattern, options: options) 26 | } catch { 27 | log.message("Failed to convert String literal '\(value)' to type Regex.", level: .error) 28 | log.exit(status: .failure) 29 | exit(EXIT_FAILURE) // only reachable in unit tests 30 | } 31 | } 32 | } 33 | 34 | extension Regex: ExpressibleByDictionaryLiteral { 35 | public init(dictionaryLiteral elements: (String, String)...) { 36 | var patternElements = elements 37 | var options: Options = Regex.defaultOptions 38 | 39 | if let regexOptionsValue = elements.last(where: { $0.0 == Constants.regexOptionsSeparator })?.1 { 40 | patternElements.removeAll { $0.0 == Constants.regexOptionsSeparator } 41 | 42 | if regexOptionsValue.contains(Constants.caseInsensitiveRegexOption) { 43 | options.insert(.ignoreCase) 44 | } 45 | 46 | if regexOptionsValue.contains(Constants.dotMatchesNewlinesRegexOption) { 47 | options.insert(.dotMatchesLineSeparators) 48 | } 49 | } 50 | 51 | do { 52 | let pattern: String = patternElements.reduce(into: "") { result, element in result.append("(?<\(element.0)>\(element.1))") } 53 | self = try Regex(pattern, options: options) 54 | } catch { 55 | log.message("Failed to convert Dictionary literal '\(elements)' to type Regex.", level: .error) 56 | log.exit(status: .failure) 57 | exit(EXIT_FAILURE) // only reachable in unit tests 58 | } 59 | } 60 | } 61 | 62 | extension Regex { 63 | /// Replaces all captures groups with the given capture references. References can be numbers like `$1` and capture names like `$prefix`. 64 | public func replaceAllCaptures(in input: String, with template: String) -> String { 65 | replacingMatches(in: input, with: numerizedNamedCaptureRefs(in: template)) 66 | } 67 | 68 | /// Numerizes references to named capture groups to work around missing named capture group replacement in `NSRegularExpression` APIs. 69 | func numerizedNamedCaptureRefs(in replacementString: String) -> String { 70 | let captureGroupNameRegex = Regex(#"\(\?\<([a-zA-Z0-9_-]+)\>[^\)]+\)"#) 71 | let captureGroupNames: [String] = captureGroupNameRegex.matches(in: pattern).map { $0.captures[0]! } 72 | return captureGroupNames.enumerated().reduce(replacementString) { result, enumeratedGroupName in 73 | result.replacingOccurrences(of: "$\(enumeratedGroupName.element)", with: "$\(enumeratedGroupName.offset + 1)") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/AnyLint/FilesSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// Helper to search for files and filter using Regexes. 5 | public final class FilesSearch { 6 | struct SearchOptions: Equatable, Hashable { 7 | let pathToSearch: String 8 | let includeFilters: [Regex] 9 | let excludeFilters: [Regex] 10 | } 11 | 12 | /// The shared instance. 13 | public static let shared = FilesSearch() 14 | 15 | private var cachedFilePaths: [SearchOptions: [String]] = [:] 16 | 17 | private init() {} 18 | 19 | /// Should be called whenever files within the current directory are renamed, moved, added or deleted. 20 | func invalidateCache() { 21 | cachedFilePaths = [:] 22 | } 23 | 24 | /// Returns all file paths within given `path` matching the given `include` and `exclude` filters. 25 | public func allFiles( // swiftlint:disable:this function_body_length 26 | within path: String, 27 | includeFilters: [Regex], 28 | excludeFilters: [Regex] = [] 29 | ) -> [String] { 30 | log.message( 31 | "Start searching for matching files in path \(path) with includeFilters \(includeFilters) and excludeFilters \(excludeFilters) ...", 32 | level: .debug 33 | ) 34 | 35 | let searchOptions = SearchOptions(pathToSearch: path, includeFilters: includeFilters, excludeFilters: excludeFilters) 36 | if let cachedFilePaths: [String] = cachedFilePaths[searchOptions] { 37 | log.message("A file search with exactly the above search options was already done and was not invalidated, using cached results ...", level: .debug) 38 | return cachedFilePaths 39 | } 40 | 41 | guard let url = URL(string: path, relativeTo: fileManager.currentDirectoryUrl) else { 42 | log.message("Could not convert path '\(path)' to type URL.", level: .error) 43 | log.exit(status: .failure) 44 | return [] // only reachable in unit tests 45 | } 46 | 47 | let propKeys = [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey] 48 | guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: propKeys, options: [], errorHandler: nil) else { 49 | log.message("Couldn't create enumerator for path '\(path)'.", level: .error) 50 | log.exit(status: .failure) 51 | return [] // only reachable in unit tests 52 | } 53 | 54 | var filePaths: [String] = [] 55 | 56 | for case let fileUrl as URL in enumerator { 57 | guard 58 | let resourceValues = try? fileUrl.resourceValues(forKeys: [URLResourceKey.isRegularFileKey, URLResourceKey.isHiddenKey]), 59 | let isHiddenFilePath = resourceValues.isHidden, 60 | let isRegularFilePath = resourceValues.isRegularFile 61 | else { 62 | log.message("Could not read resource values for file at \(fileUrl.path)", level: .error) 63 | log.exit(status: .failure) 64 | return [] // only reachable in unit tests 65 | } 66 | 67 | // skip if any exclude filter applies 68 | if excludeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) { 69 | if !isRegularFilePath { 70 | enumerator.skipDescendants() 71 | } 72 | 73 | continue 74 | } 75 | 76 | // skip hidden files and directories 77 | #if os(Linux) 78 | if isHiddenFilePath || fileUrl.path.contains("/.") || fileUrl.path.starts(with: ".") { 79 | if !isRegularFilePath { 80 | enumerator.skipDescendants() 81 | } 82 | 83 | continue 84 | } 85 | #else 86 | if isHiddenFilePath { 87 | if !isRegularFilePath { 88 | enumerator.skipDescendants() 89 | } 90 | 91 | continue 92 | } 93 | #endif 94 | 95 | guard isRegularFilePath, includeFilters.contains(where: { $0.matches(fileUrl.relativePathFromCurrent) }) else { continue } 96 | 97 | filePaths.append(fileUrl.relativePathFromCurrent) 98 | } 99 | 100 | cachedFilePaths[searchOptions] = filePaths 101 | return filePaths 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/LintTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | final class LintTests: XCTestCase { 6 | override func setUp() { 7 | log = Logger(outputType: .test) 8 | TestHelper.shared.reset() 9 | } 10 | 11 | func testValidateRegexMatchesForEach() { 12 | XCTAssertNil(TestHelper.shared.exitStatus) 13 | 14 | let regex: Regex = #"foo[0-9]?bar"# 15 | let checkInfo = CheckInfo(id: "foo_bar", hint: "do bar", severity: .warning) 16 | 17 | Lint.validate( 18 | regex: regex, 19 | matchesForEach: ["foo1bar", "foobar", "myfoo4barbeque"], 20 | checkInfo: checkInfo 21 | ) 22 | XCTAssertNil(TestHelper.shared.exitStatus) 23 | 24 | Lint.validate( 25 | regex: regex, 26 | matchesForEach: ["foo1bar", "FooBar", "myfoo4barbeque"], 27 | checkInfo: checkInfo 28 | ) 29 | XCTAssertEqual(TestHelper.shared.exitStatus, .failure) 30 | } 31 | 32 | func testValidateRegexDoesNotMatchAny() { 33 | XCTAssertNil(TestHelper.shared.exitStatus) 34 | 35 | let regex: Regex = #"foo[0-9]?bar"# 36 | let checkInfo = CheckInfo(id: "foo_bar", hint: "do bar", severity: .warning) 37 | 38 | Lint.validate( 39 | regex: regex, 40 | doesNotMatchAny: ["fooLbar", "FooBar", "myfoo40barbeque"], 41 | checkInfo: checkInfo 42 | ) 43 | XCTAssertNil(TestHelper.shared.exitStatus) 44 | 45 | Lint.validate( 46 | regex: regex, 47 | doesNotMatchAny: ["fooLbar", "foobar", "myfoo40barbeque"], 48 | checkInfo: checkInfo 49 | ) 50 | XCTAssertEqual(TestHelper.shared.exitStatus, .failure) 51 | } 52 | 53 | func testValidateAutocorrectsAllExamplesWithAnonymousGroups() { 54 | XCTAssertNil(TestHelper.shared.exitStatus) 55 | 56 | let anonymousCaptureRegex = try? Regex(#"([^\.]+)(\.)([^\.]+)(\.)([^\.]+)"#) 57 | 58 | Lint.validateAutocorrectsAll( 59 | checkInfo: CheckInfo(id: "id", hint: "hint"), 60 | examples: [ 61 | AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), 62 | AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), 63 | ], 64 | regex: anonymousCaptureRegex!, 65 | autocorrectReplacement: "$5$2$3$4$1" 66 | ) 67 | 68 | XCTAssertNil(TestHelper.shared.exitStatus) 69 | 70 | Lint.validateAutocorrectsAll( 71 | checkInfo: CheckInfo(id: "id", hint: "hint"), 72 | examples: [ 73 | AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), 74 | AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), 75 | ], 76 | regex: anonymousCaptureRegex!, 77 | autocorrectReplacement: "$4$1$2$3$0" 78 | ) 79 | 80 | XCTAssertEqual(TestHelper.shared.exitStatus, .failure) 81 | } 82 | 83 | func testValidateAutocorrectsAllExamplesWithNamedGroups() { 84 | XCTAssertNil(TestHelper.shared.exitStatus) 85 | 86 | let namedCaptureRegex: Regex = [ 87 | "prefix": #"[^\.]+"#, 88 | "separator1": #"\."#, 89 | "content": #"[^\.]+"#, 90 | "separator2": #"\."#, 91 | "suffix": #"[^\.]+"#, 92 | ] 93 | 94 | Lint.validateAutocorrectsAll( 95 | checkInfo: CheckInfo(id: "id", hint: "hint"), 96 | examples: [ 97 | AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), 98 | AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), 99 | ], 100 | regex: namedCaptureRegex, 101 | autocorrectReplacement: "$suffix$separator1$content$separator2$prefix" 102 | ) 103 | 104 | XCTAssertNil(TestHelper.shared.exitStatus) 105 | 106 | Lint.validateAutocorrectsAll( 107 | checkInfo: CheckInfo(id: "id", hint: "hint"), 108 | examples: [ 109 | AutoCorrection(before: "prefix.content.suffix", after: "suffix.content.prefix"), 110 | AutoCorrection(before: "forums.swift.org", after: "org.swift.forums"), 111 | ], 112 | regex: namedCaptureRegex, 113 | autocorrectReplacement: "$sfx$sep1$cnt$sep2$pref" 114 | ) 115 | 116 | XCTAssertEqual(TestHelper.shared.exitStatus, .failure) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Utility/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rainbow 3 | 4 | /// Helper to log output to console or elsewhere. 5 | public final class Logger { 6 | /// The print level type. 7 | public enum PrintLevel: String { 8 | /// Print success information. 9 | case success 10 | 11 | /// Print any kind of information potentially interesting to users. 12 | case info 13 | 14 | /// Print information that might potentially be problematic. 15 | case warning 16 | 17 | /// Print information that probably is problematic. 18 | case error 19 | 20 | /// Print detailed information for debugging purposes. 21 | case debug 22 | 23 | var color: Color { 24 | switch self { 25 | case .success: 26 | return Color.lightGreen 27 | 28 | case .info: 29 | return Color.lightBlue 30 | 31 | case .warning: 32 | return Color.yellow 33 | 34 | case .error: 35 | return Color.red 36 | 37 | case .debug: 38 | return Color.default 39 | } 40 | } 41 | } 42 | 43 | /// The output type. 44 | public enum OutputType: String { 45 | /// Output is targeted to a console to be read by developers. 46 | case console 47 | 48 | /// Output is targeted to Xcodes left pane to be interpreted by it to mark errors & warnings. 49 | case xcode 50 | 51 | /// Output is targeted for unit tests. Collect into globally accessible TestHelper. 52 | case test 53 | } 54 | 55 | /// The exit status. 56 | public enum ExitStatus { 57 | /// Successfully finished task. 58 | case success 59 | 60 | /// Failed to finish task. 61 | case failure 62 | 63 | var statusCode: Int32 { 64 | switch self { 65 | case .success: 66 | return EXIT_SUCCESS 67 | 68 | case .failure: 69 | return EXIT_FAILURE 70 | } 71 | } 72 | } 73 | 74 | /// The output type of the logger. 75 | public let outputType: OutputType 76 | 77 | /// Defines if the log should include debug logs. 78 | public var logDebugLevel: Bool = false 79 | 80 | /// Initializes a new Logger object with a given output type. 81 | public init(outputType: OutputType) { 82 | self.outputType = outputType 83 | } 84 | 85 | /// Communicates a message to the chosen output target with proper formatting based on level & source. 86 | /// 87 | /// - Parameters: 88 | /// - message: The message to be printed. Don't include `Error!`, `Warning!` or similar information at the beginning. 89 | /// - level: The level of the print statement. 90 | public func message(_ message: String, level: PrintLevel) { 91 | guard level != .debug || logDebugLevel else { return } 92 | 93 | switch outputType { 94 | case .console: 95 | consoleMessage(message, level: level) 96 | 97 | case .xcode: 98 | xcodeMessage(message, level: level) 99 | 100 | case .test: 101 | TestHelper.shared.consoleOutputs.append((message, level)) 102 | } 103 | } 104 | 105 | /// Exits the current program with the given status. 106 | public func exit(status: ExitStatus) { 107 | switch outputType { 108 | case .console, .xcode: 109 | #if os(Linux) 110 | Glibc.exit(status.statusCode) 111 | #else 112 | Darwin.exit(status.statusCode) 113 | #endif 114 | 115 | case .test: 116 | TestHelper.shared.exitStatus = status 117 | } 118 | } 119 | 120 | private func consoleMessage(_ message: String, level: PrintLevel) { 121 | switch level { 122 | case .success: 123 | print(formattedCurrentTime(), "✅", message.green) 124 | 125 | case .info: 126 | print(formattedCurrentTime(), "ℹ️ ", message.lightCyan) 127 | 128 | case .warning: 129 | print(formattedCurrentTime(), "⚠️ ", message.yellow) 130 | 131 | case .error: 132 | print(formattedCurrentTime(), "❌", message.red) 133 | 134 | case .debug: 135 | print(formattedCurrentTime(), "💬", message) 136 | } 137 | } 138 | 139 | /// Reports a message in an Xcode compatible format to be shown in the left pane. 140 | /// 141 | /// - Parameters: 142 | /// - message: The message to be printed. Don't include `Error!`, `Warning!` or similar information at the beginning. 143 | /// - level: The level of the print statement. 144 | /// - location: The file, line and char in line location string. 145 | public func xcodeMessage(_ message: String, level: PrintLevel, location: String? = nil) { 146 | if let location = location { 147 | print("\(location) \(level.rawValue): \(Constants.toolName): \(message)") 148 | } else { 149 | print("\(level.rawValue): \(Constants.toolName): \(message)") 150 | } 151 | } 152 | 153 | private func formattedCurrentTime() -> String { 154 | let dateFormatter = DateFormatter() 155 | dateFormatter.dateFormat = "HH:mm:ss.SSS" 156 | let dateTime = dateFormatter.string(from: Date()) 157 | return "\(dateTime):" 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/StatisticsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | import Rainbow 3 | @testable import Utility 4 | import XCTest 5 | 6 | final class StatisticsTests: XCTestCase { 7 | override func setUp() { 8 | log = Logger(outputType: .test) 9 | TestHelper.shared.reset() 10 | Statistics.shared.reset() 11 | } 12 | 13 | func testFoundViolationsInCheck() { 14 | XCTAssert(Statistics.shared.executedChecks.isEmpty) 15 | XCTAssert(Statistics.shared.violationsBySeverity[.info]!.isEmpty) 16 | XCTAssert(Statistics.shared.violationsBySeverity[.warning]!.isEmpty) 17 | XCTAssert(Statistics.shared.violationsBySeverity[.error]!.isEmpty) 18 | XCTAssert(Statistics.shared.violationsPerCheck.isEmpty) 19 | 20 | let checkInfo1 = CheckInfo(id: "id1", hint: "hint1", severity: .info) 21 | Statistics.shared.found( 22 | violations: [Violation(checkInfo: checkInfo1)], 23 | in: checkInfo1 24 | ) 25 | 26 | XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1]) 27 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) 28 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 0) 29 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 0) 30 | XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 1) 31 | 32 | let checkInfo2 = CheckInfo(id: "id2", hint: "hint2", severity: .warning) 33 | Statistics.shared.found( 34 | violations: [Violation(checkInfo: checkInfo2), Violation(checkInfo: checkInfo2)], 35 | in: CheckInfo(id: "id2", hint: "hint2", severity: .warning) 36 | ) 37 | 38 | XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1, checkInfo2]) 39 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) 40 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 2) 41 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 0) 42 | XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 2) 43 | 44 | let checkInfo3 = CheckInfo(id: "id3", hint: "hint3", severity: .error) 45 | Statistics.shared.found( 46 | violations: [Violation(checkInfo: checkInfo3), Violation(checkInfo: checkInfo3), Violation(checkInfo: checkInfo3)], 47 | in: CheckInfo(id: "id3", hint: "hint3", severity: .error) 48 | ) 49 | 50 | XCTAssertEqual(Statistics.shared.executedChecks, [checkInfo1, checkInfo2, checkInfo3]) 51 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.info]!.count, 1) 52 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.warning]!.count, 2) 53 | XCTAssertEqual(Statistics.shared.violationsBySeverity[.error]!.count, 3) 54 | XCTAssertEqual(Statistics.shared.violationsPerCheck.keys.count, 3) 55 | } 56 | 57 | func testLogSummary() { // swiftlint:disable:this function_body_length 58 | Statistics.shared.logCheckSummary(printExecutionTime: false) 59 | XCTAssertEqual(TestHelper.shared.consoleOutputs.count, 1) 60 | XCTAssertEqual(TestHelper.shared.consoleOutputs[0].level, .warning) 61 | XCTAssertEqual(TestHelper.shared.consoleOutputs[0].message, "No checks found to perform.") 62 | 63 | TestHelper.shared.reset() 64 | 65 | let checkInfo1 = CheckInfo(id: "id1", hint: "hint1", severity: .info) 66 | Statistics.shared.found( 67 | violations: [Violation(checkInfo: checkInfo1)], 68 | in: checkInfo1 69 | ) 70 | 71 | let checkInfo2 = CheckInfo(id: "id2", hint: "hint2", severity: .warning) 72 | Statistics.shared.found( 73 | violations: [ 74 | Violation(checkInfo: checkInfo2, filePath: "Hogwarts/Harry.swift"), 75 | Violation(checkInfo: checkInfo2, filePath: "Hogwarts/Albus.swift"), 76 | ], 77 | in: CheckInfo(id: "id2", hint: "hint2", severity: .warning) 78 | ) 79 | 80 | let checkInfo3 = CheckInfo(id: "id3", hint: "hint3", severity: .error) 81 | Statistics.shared.found( 82 | violations: [ 83 | Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Harry.swift", locationInfo: (line: 10, charInLine: 30)), 84 | Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Harry.swift", locationInfo: (line: 72, charInLine: 17)), 85 | Violation(checkInfo: checkInfo3, filePath: "Hogwarts/Albus.swift", locationInfo: (line: 40, charInLine: 4)), 86 | ], 87 | in: CheckInfo(id: "id3", hint: "hint3", severity: .error) 88 | ) 89 | 90 | Statistics.shared.checkedFiles(at: ["Hogwarts/Harry.swift"]) 91 | Statistics.shared.checkedFiles(at: ["Hogwarts/Harry.swift", "Hogwarts/Albus.swift"]) 92 | Statistics.shared.checkedFiles(at: ["Hogwarts/Albus.swift"]) 93 | 94 | Statistics.shared.logCheckSummary(printExecutionTime: true) 95 | 96 | XCTAssertEqual( 97 | TestHelper.shared.consoleOutputs.map { $0.level }, 98 | [.info, .info, .info, .warning, .warning, .warning, .warning, .error, .error, .error, .error, .error, .error] 99 | ) 100 | 101 | let expectedOutputs = [ 102 | "⏱ Executed checks sorted by their execution time:", 103 | "\("[id1]".bold) Found 1 violation(s).", 104 | ">> Hint: hint1".bold.italic, 105 | "\("[id2]".bold) Found 2 violation(s) at:", 106 | 107 | "> 1. Hogwarts/Harry.swift", 108 | "> 2. Hogwarts/Albus.swift", 109 | ">> Hint: hint2".bold.italic, 110 | "\("[id3]".bold) Found 3 violation(s) at:", 111 | "> 1. Hogwarts/Harry.swift:10:30:", 112 | "> 2. Hogwarts/Harry.swift:72:17:", 113 | "> 3. Hogwarts/Albus.swift:40:4:", 114 | ">> Hint: hint3".bold.italic, 115 | "Performed 3 check(s) in 2 file(s) and found 3 error(s) & 2 warning(s).", 116 | ] 117 | 118 | XCTAssertEqual(TestHelper.shared.consoleOutputs.count, expectedOutputs.count) 119 | 120 | for (index, expectedOutput) in expectedOutputs.enumerated() { 121 | XCTAssertEqual(TestHelper.shared.consoleOutputs[index].message, expectedOutput) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/AnyLint/Checkers/FileContentsChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | struct FileContentsChecker { 5 | let checkInfo: CheckInfo 6 | let regex: Regex 7 | let violationLocation: ViolationLocationConfig 8 | let filePathsToCheck: [String] 9 | let autoCorrectReplacement: String? 10 | let repeatIfAutoCorrected: Bool 11 | } 12 | 13 | extension FileContentsChecker: Checker { 14 | func performCheck() throws -> [Violation] { // swiftlint:disable:this function_body_length 15 | log.message("Start checking \(checkInfo) ...", level: .debug) 16 | var violations: [Violation] = [] 17 | 18 | for filePath in filePathsToCheck.reversed() { 19 | log.message("Start reading contents of file at \(filePath) ...", level: .debug) 20 | 21 | if let fileData = fileManager.contents(atPath: filePath), let fileContents = String(data: fileData, encoding: .utf8) { 22 | var newFileContents: String = fileContents 23 | let linesInFile: [String] = fileContents.components(separatedBy: .newlines) 24 | 25 | // skip check in file if contains `AnyLint.skipInFile: ` 26 | let skipInFileRegex = try Regex(#"AnyLint\.skipInFile:[^\n]*([, ]All[,\s]|[, ]\#(checkInfo.id)[,\s])"#) 27 | guard !skipInFileRegex.matches(fileContents) else { 28 | log.message("Skipping \(checkInfo) in file \(filePath) due to 'AnyLint.skipInFile' instruction ...", level: .debug) 29 | continue 30 | } 31 | 32 | let skipHereRegex = try Regex(#"AnyLint\.skipHere:[^\n]*[, ]\#(checkInfo.id)"#) 33 | 34 | for match in regex.matches(in: fileContents).reversed() { 35 | let locationInfo: String.LocationInfo 36 | 37 | switch self.violationLocation.range { 38 | case .fullMatch: 39 | switch self.violationLocation.bound { 40 | case .lower: 41 | locationInfo = fileContents.locationInfo(of: match.range.lowerBound) 42 | 43 | case .upper: 44 | locationInfo = fileContents.locationInfo(of: match.range.upperBound) 45 | } 46 | 47 | case .captureGroup(let index): 48 | let capture = match.captures[index]! 49 | let captureRange = NSRange(match.string.range(of: capture)!, in: match.string) 50 | 51 | switch self.violationLocation.bound { 52 | case .lower: 53 | locationInfo = fileContents.locationInfo( 54 | of: fileContents.index(match.range.lowerBound, offsetBy: captureRange.location) 55 | ) 56 | 57 | case .upper: 58 | locationInfo = fileContents.locationInfo( 59 | of: fileContents.index(match.range.lowerBound, offsetBy: captureRange.location + captureRange.length) 60 | ) 61 | } 62 | } 63 | 64 | log.message("Found violating match at \(locationInfo) ...", level: .debug) 65 | 66 | // skip found match if contains `AnyLint.skipHere: ` in same line or one line before 67 | guard !linesInFile.containsLine(at: [locationInfo.line - 2, locationInfo.line - 1], matchingRegex: skipHereRegex) else { 68 | log.message("Skip reporting last match due to 'AnyLint.skipHere' instruction ...", level: .debug) 69 | continue 70 | } 71 | 72 | let autoCorrection: AutoCorrection? = { 73 | guard let autoCorrectReplacement = autoCorrectReplacement else { return nil } 74 | 75 | let newMatchString = regex.replaceAllCaptures(in: match.string, with: autoCorrectReplacement) 76 | return AutoCorrection(before: match.string, after: newMatchString) 77 | }() 78 | 79 | if let autoCorrection = autoCorrection { 80 | guard match.string != autoCorrection.after else { 81 | // can skip auto-correction & violation reporting because auto-correct replacement is equal to matched string 82 | continue 83 | } 84 | 85 | // apply auto correction 86 | newFileContents.replaceSubrange(match.range, with: autoCorrection.after) 87 | log.message("Applied autocorrection for last match ...", level: .debug) 88 | } 89 | 90 | log.message("Reporting violation for \(checkInfo) in file \(filePath) at \(locationInfo) ...", level: .debug) 91 | violations.append( 92 | Violation( 93 | checkInfo: checkInfo, 94 | filePath: filePath, 95 | matchedString: match.string, 96 | locationInfo: locationInfo, 97 | appliedAutoCorrection: autoCorrection 98 | ) 99 | ) 100 | } 101 | 102 | if newFileContents != fileContents { 103 | log.message("Rewriting contents of file \(filePath) due to autocorrection changes ...", level: .debug) 104 | try newFileContents.write(toFile: filePath, atomically: true, encoding: .utf8) 105 | } 106 | } else { 107 | log.message( 108 | "Could not read contents of file at \(filePath). Make sure it is a text file and is formatted as UTF8.", 109 | level: .warning 110 | ) 111 | } 112 | 113 | Statistics.shared.checkedFiles(at: [filePath]) 114 | } 115 | 116 | violations = violations.reversed() 117 | 118 | if repeatIfAutoCorrected && violations.contains(where: { $0.appliedAutoCorrection != nil }) { 119 | log.message("Repeating check \(checkInfo) because auto-corrections were applied on last run.", level: .debug) 120 | 121 | // only paths where auto-corrections were applied need to be re-checked 122 | let filePathsToReCheck = Array(Set(violations.filter { $0.appliedAutoCorrection != nil }.map { $0.filePath! })).sorted() 123 | 124 | let violationsOnRechecks = try FileContentsChecker( 125 | checkInfo: checkInfo, 126 | regex: regex, 127 | violationLocation: self.violationLocation, 128 | filePathsToCheck: filePathsToReCheck, 129 | autoCorrectReplacement: autoCorrectReplacement, 130 | repeatIfAutoCorrected: repeatIfAutoCorrected 131 | ).performCheck() 132 | violations.append(contentsOf: violationsOnRechecks) 133 | } 134 | 135 | return violations 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/AnyLint/Statistics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | final class Statistics { 5 | static let shared = Statistics() 6 | 7 | var executedChecks: [CheckInfo] = [] 8 | var violationsPerCheck: [CheckInfo: [Violation]] = [:] 9 | var violationsBySeverity: [Severity: [Violation]] = [.info: [], .warning: [], .error: []] 10 | var filesChecked: Set = [] 11 | var executionTimePerCheck: [CheckInfo: TimeInterval] = [:] 12 | 13 | var maxViolationSeverity: Severity? { 14 | violationsBySeverity.keys.filter { !violationsBySeverity[$0]!.isEmpty }.max { $0.rawValue < $1.rawValue } 15 | } 16 | 17 | private init() {} 18 | 19 | func checkedFiles(at filePaths: [String]) { 20 | filePaths.forEach { filesChecked.insert($0) } 21 | } 22 | 23 | func found(violations: [Violation], in check: CheckInfo) { 24 | executedChecks.append(check) 25 | violationsPerCheck[check] = violations 26 | violationsBySeverity[check.severity]!.append(contentsOf: violations) 27 | } 28 | 29 | func measureTime(check: CheckInfo, lintTaskClosure: () throws -> Void) rethrows { 30 | let startedAt = Date() 31 | try lintTaskClosure() 32 | self.executionTimePerCheck[check] = Date().timeIntervalSince(startedAt) 33 | } 34 | 35 | /// Use for unit testing only. 36 | func reset() { 37 | executedChecks = [] 38 | violationsPerCheck = [:] 39 | violationsBySeverity = [.info: [], .warning: [], .error: []] 40 | filesChecked = [] 41 | } 42 | 43 | func logValidationSummary() { 44 | guard log.outputType != .xcode else { 45 | log.message("Performing validations only while reporting for Xcode is probably misuse of the `-l` / `--validate` option.", level: .warning) 46 | return 47 | } 48 | 49 | if executedChecks.isEmpty { 50 | log.message("No checks found to validate.", level: .warning) 51 | } else { 52 | log.message( 53 | "Performed \(executedChecks.count) validation(s) in \(filesChecked.count) file(s) without any issues.", 54 | level: .success 55 | ) 56 | } 57 | } 58 | 59 | func logCheckSummary(printExecutionTime: Bool) { 60 | // make sure first violation reports in a new line when e.g. 'swift-driver version: 1.45.2' is printed 61 | print("\n") // AnyLint.skipHere: Logger 62 | 63 | if executedChecks.isEmpty { 64 | log.message("No checks found to perform.", level: .warning) 65 | } else if violationsBySeverity.values.contains(where: { $0.isFilled }) { 66 | if printExecutionTime { 67 | self.logExecutionTimes() 68 | } 69 | 70 | switch log.outputType { 71 | case .console, .test: 72 | logViolationsToConsole() 73 | 74 | case .xcode: 75 | showViolationsInXcode() 76 | } 77 | } else { 78 | if printExecutionTime { 79 | self.logExecutionTimes() 80 | } 81 | 82 | log.message( 83 | "Performed \(executedChecks.count) check(s) in \(filesChecked.count) file(s) without any violations.", 84 | level: .success 85 | ) 86 | } 87 | } 88 | 89 | func logExecutionTimes() { 90 | log.message("⏱ Executed checks sorted by their execution time:", level: .info) 91 | 92 | for (check, executionTime) in self.executionTimePerCheck.sorted(by: { $0.value > $1.value }) { 93 | let milliseconds = Int(executionTime * 1_000) 94 | log.message("\(milliseconds)ms\t\(check.id)", level: .info) 95 | } 96 | } 97 | 98 | func violations(severity: Severity, excludeAutocorrected: Bool) -> [Violation] { 99 | let violations: [Violation] = violationsBySeverity[severity]! 100 | guard excludeAutocorrected else { return violations } 101 | return violations.filter { $0.appliedAutoCorrection == nil } 102 | } 103 | 104 | private func logViolationsToConsole() { 105 | for check in executedChecks { 106 | if let checkViolations = violationsPerCheck[check], checkViolations.isFilled { 107 | let violationsWithLocationMessage = checkViolations.filter { $0.locationMessage(pathType: .relative) != nil } 108 | 109 | if violationsWithLocationMessage.isFilled { 110 | log.message( 111 | "\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s) at:", 112 | level: check.severity.logLevel 113 | ) 114 | let numerationDigits = String(violationsWithLocationMessage.count).count 115 | 116 | for (index, violation) in violationsWithLocationMessage.enumerated() { 117 | let violationNumString = String(format: "%0\(numerationDigits)d", index + 1) 118 | let prefix = "> \(violationNumString). " 119 | log.message(prefix + violation.locationMessage(pathType: .relative)!, level: check.severity.logLevel) 120 | 121 | let prefixLengthWhitespaces = (0 ..< prefix.count).map { _ in " " }.joined() 122 | if let appliedAutoCorrection = violation.appliedAutoCorrection { 123 | for messageLine in appliedAutoCorrection.appliedMessageLines { 124 | log.message(prefixLengthWhitespaces + messageLine, level: .info) 125 | } 126 | } else if let matchedString = violation.matchedString { 127 | log.message(prefixLengthWhitespaces + "Matching string:".bold + " (trimmed & reduced whitespaces)", level: .info) 128 | let matchedStringOutput = matchedString 129 | .showNewlines() 130 | .trimmingCharacters(in: .whitespacesAndNewlines) 131 | .replacingOccurrences(of: " ", with: " ") 132 | .replacingOccurrences(of: " ", with: " ") 133 | .replacingOccurrences(of: " ", with: " ") 134 | log.message(prefixLengthWhitespaces + "> " + matchedStringOutput, level: .info) 135 | } 136 | } 137 | } else { 138 | log.message("\("[\(check.id)]".bold) Found \(checkViolations.count) violation(s).", level: check.severity.logLevel) 139 | } 140 | 141 | log.message(">> Hint: \(check.hint)".bold.italic, level: check.severity.logLevel) 142 | } 143 | } 144 | 145 | let errors = "\(violationsBySeverity[.error]!.count) error(s)" 146 | let warnings = "\(violationsBySeverity[.warning]!.count) warning(s)" 147 | 148 | log.message( 149 | "Performed \(executedChecks.count) check(s) in \(filesChecked.count) file(s) and found \(errors) & \(warnings).", 150 | level: maxViolationSeverity!.logLevel 151 | ) 152 | } 153 | 154 | private func showViolationsInXcode() { 155 | for severity in violationsBySeverity.keys.sorted().reversed() { 156 | let severityViolations = violationsBySeverity[severity]! 157 | for violation in severityViolations where violation.appliedAutoCorrection == nil { 158 | let check = violation.checkInfo 159 | log.xcodeMessage( 160 | "[\(check.id)] \(check.hint)", 161 | level: check.severity.logLevel, 162 | location: violation.locationMessage(pathType: .absolute) 163 | ) 164 | } 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). 5 | 6 |
7 | Formatting Rules for Entries 8 | Each entry should use the following format: 9 | 10 | ```markdown 11 | - Summary of what was changed in a single line using past tense & followed by two whitespaces. 12 | Issue: [#0](https://github.com/FlineDev/AnyLint/issues/0) | PR: [#0](https://github.com/FlineDev/AnyLint/pull/0) | Author: [Cihat Gündüz](https://github.com/Jeehut) 13 | ``` 14 | 15 | Note that at the end of the summary line, you need to add two whitespaces (` `) for correct rendering on GitHub. 16 | 17 | If needed, pluralize to `Tasks`, `PRs` or `Authors` and list multiple entries separated by `, `. Also, remove entries not needed in the second line. 18 |
19 | 20 | ## [Unreleased] 21 | ### Added 22 | - None. 23 | ### Changed 24 | - None. 25 | ### Deprecated 26 | - None. 27 | ### Removed 28 | - None. 29 | ### Fixed 30 | - None. 31 | ### Security 32 | - None. 33 | 34 | ## [0.11.0] - 2023-04-09 35 | ### Added 36 | - Added a new `--unvalidated` (`-u`) option for running all checks without running the validations provided, such as testing for `matchingExamples` and `nonMatchingExamples`. Use with cuation. 37 | ### Changed 38 | - Some internal code clean-up. 39 | - Upgrade to Swift 5.7 manifest syntax. 40 | ### Fixed 41 | - The `--measure` option also measured validations & files search which distorted the measure time for the first check with the same files search. Now, it only measures the actual matching time of the Regex for better evaluation. 42 | 43 | ## [0.10.1] - 2022-05-27 44 | ### Changed 45 | - Improved output color & formatting of new `--measure` option for printing execution time per check. 46 | Author: [Cihat Gündüz](https://github.com/Jeehut) 47 | ### Fixed 48 | - New `--measure` option did not work when no violations were found, now also prints when all checks succeed. 49 | Author: [Cihat Gündüz](https://github.com/Jeehut) 50 | 51 | ## [0.10.0] - 2022-05-27 52 | ### Added 53 | - New `--measure` / `-m` option to print execution times per check to find slow checks easily. 54 | Author: [Cihat Gündüz](https://github.com/Jeehut) 55 | ### Changed 56 | - The execution time of all checks are now being measured, independent of what options are provided. 57 | Author: [Cihat Gündüz](https://github.com/Jeehut) 58 | 59 | ## [0.9.2] - 2022-04-25 60 | ### Added 61 | - Allow `customCheck` closure to be throwing by re-throwing if they are. 62 | Author: [Cihat Gündüz](https://github.com/Jeehut) 63 | 64 | ## [0.9.1] - 2022-04-25 65 | ### Changed 66 | - Default violation level was changed from `error` to `warning`. 67 | Author: [Cihat Gündüz](https://github.com/Jeehut) | Issue: [#47](https://github.com/FlineDev/AnyLint/issues/47) 68 | 69 | ## [0.9.0] - 2022-04-24 70 | ### Added 71 | - Added new option `violationLocation` parameter for `checkFileContents` for specifying position of violation marker using `.init(range:bound:)`, where `range` can be one of `.fullMatch` or `.captureGroup(index:)` and bound one of `.lower` or `.upper`. 72 | 73 | ## [0.8.5] - 2022-04-24 74 | ### Fixed 75 | - Fixed an issue where first violation can't be shown in Xcode due to 'swift-driver version: 1.45.2' printed on same line. 76 | 77 | ## [0.8.4] - 2022-04-01 78 | ### Fixed 79 | - Fixed an issue with pointing to the wrong Swift-SH path on Apple Silicon Macs. Should also fix the path on Linux. 80 | Author: [Cihat Gündüz](https://github.com/Jeehut) | Issue: [#46](https://github.com/FlineDev/AnyLint/issues/46) 81 | 82 | ## [0.8.3] - 2021-10-13 83 | ### Changed 84 | - Bumped minimum required Swift tools version to 5.4. 85 | Author: [Cihat Gündüz](https://github.com/Jeehut) 86 | - Removed `Package.resolved` file to prevent pinning dependency versions. 87 | Author: [Cihat Gündüz](https://github.com/Jeehut) 88 | 89 | ## [0.8.2] - 2020-06-09 90 | ### Changed 91 | - Made internal extension methos public for usage in `customCheck`. 92 | PR: [#35](https://github.com/FlineDev/AnyLint/pull/35) | Author: [Cihat Gündüz](https://github.com/Jeehut) 93 | - Print diff out to console for multiline autocorrections that were applied. 94 | Issue: [#27](https://github.com/FlineDev/AnyLint/issues/27) | PR: [#35](https://github.com/FlineDev/AnyLint/pull/35) | Author: [Cihat Gündüz](https://github.com/Jeehut) 95 | 96 | ## [0.8.1] - 2020-06-08 97 | ### Changed 98 | - Made internal methods in types `FilesSearch` and `Violation` public for usage in `customCheck`. 99 | PR: [#34](https://github.com/FlineDev/AnyLint/pull/34) | Author: [Cihat Gündüz](https://github.com/Jeehut) 100 | 101 | ## [0.8.0] - 2020-05-18 102 | ### Added 103 | - Added new `repeatIfAutoCorrected` option to `checkFileContents` method to repeat the check if last run did any auto-corrections. 104 | Issue: [#29](https://github.com/FlineDev/AnyLint/issues/29) | PR: [#31](https://github.com/FlineDev/AnyLint/pull/31) | Author: [Cihat Gündüz](https://github.com/Jeehut) 105 | - Added new Regex Cheat Sheet section to README including a tip on how to workaround the pointer issue. 106 | Issue: [#3](https://github.com/FlineDev/AnyLint/issues/3) | PR: [#32](https://github.com/FlineDev/AnyLint/pull/32) | Author: [Cihat Gündüz](https://github.com/Jeehut) 107 | 108 | ## [0.7.0] - 2020-05-18 109 | ### Added 110 | - A new AnyLint custom check was added to ensure `AnyLint` fails when `LinuxMain.swift` isn't up-to-date, useful as a git pre-commit hook. 111 | Author: [Cihat Gündüz](https://github.com/Jeehut) | PR: [#28](https://github.com/FlineDev/AnyLint/pull/28) 112 | ### Changed 113 | - When a given `autoCorrectReplacement` on the `checkFileContents` method leads to no changes, the matched string of the given `regex` is considered to be already correct, thus no violation is reported anymore. 114 | Issue: [#26](https://github.com/FlineDev/AnyLint/issues/26) | PR: [#28](https://github.com/FlineDev/AnyLint/pull/28) | Author: [Cihat Gündüz](https://github.com/Jeehut) 115 | - A CI pipeline using GitHub Actions was setup, which is much faster as it runs multiple tasks in parallel than Bitrise. 116 | Author: [Cihat Gündüz](https://github.com/Jeehut) 117 | 118 | ## [0.6.3] - 2020-05-07 119 | ### Added 120 | - Summary output states how many files have been checked to make it easier to find include/exclude regexes. 121 | Author: [Cihat Gündüz](https://github.com/Jeehut) 122 | - Made `Violation` public for usage in `customCheck` methods. 123 | Author: [Cihat Gündüz](https://github.com/Jeehut) 124 | ### Changed 125 | - Removed version specifier from `lint.swift` file to get always latest `AnyLint` library. 126 | Author: [Cihat Gündüz](https://github.com/Jeehut) 127 | 128 | ## [0.6.2] - 2020-04-30 129 | ### Fixed 130 | - Attempt to fix an issue that lead to failed builds with an error on Linux CI servers. 131 | Issue: [#22](https://github.com/FlineDev/AnyLint/issues/22) | Author: [Cihat Gündüz](https://github.com/Jeehut) 132 | 133 | ## [0.6.1] - 2020-04-25 134 | ### Changed 135 | - Hugely improved performance of subsequent file searches with the same combination of `includeFilters` and `excludeFilters`. For example, if 30 checks were sharing the same filters, each file search is now ~8x faster. 136 | Issue: [#20](https://github.com/FlineDev/AnyLint/issues/20) | PR: [#21](https://github.com/FlineDev/AnyLint/pull/21) | Author: [Cihat Gündüz](https://github.com/Jeehut) 137 | 138 | ## [0.6.0] - 2020-04-23 139 | ### Added 140 | - Added a way to specify Regex options for literal initialization via `/i`, `/m` (String) or `#"\"#: "im"` (Dictionary). 141 | PR: [#18](https://github.com/FlineDev/AnyLint/pull/18) | Author: [Cihat Gündüz](https://github.com/Jeehut) 142 | 143 | ## [0.5.0] - 2020-04-22 144 | ### Added 145 | - New `-s` / `--strict` option to fail on warnings as well (by default fails only on errors). 146 | PR: [#15](https://github.com/FlineDev/AnyLint/pull/15) | Author: [Cihat Gündüz](https://github.com/Jeehut) 147 | - New `-l` / `--validate` option to only runs validations for `matchingExamples`, `nonMatchingExamples` and `autoCorrectExamples`. 148 | PR: [#17](https://github.com/FlineDev/AnyLint/pull/17) | Author: [Cihat Gündüz](https://github.com/Jeehut) 149 | 150 | ## [0.4.0] - 2020-04-20 151 | ### Added 152 | - New `-d` / `--debug` option to log more info about what AnyLint is doing. Required to add a checks completion block in `logSummaryAndExit` and moved it up in the blank template. 153 | PR: [#13](https://github.com/FlineDev/AnyLint/pull/13) | Author: [Cihat Gündüz](https://github.com/Jeehut) 154 | 155 | ## [0.3.0] - 2020-04-16 156 | ### Added 157 | - Made `AutoCorrection` expressible by Dictionary literals and updated the `README.md` accordingly. 158 | Issue: [#5](https://github.com/FlineDev/AnyLint/issues/5) | PR: [#11](https://github.com/FlineDev/AnyLint/pull/11) | Author: [Cihat Gündüz](https://github.com/Jeehut) 159 | - Added option to skip checks within file contents by specifying `AnyLint.skipHere: ` or `AnyLint.skipInFile: `. Checkout the [Skip file content checks](https://github.com/FlineDev/AnyLint#skip-file-content-checks) README section for more info. 160 | Issue: [#9](https://github.com/FlineDev/AnyLint/issues/9) | PR: [#12](https://github.com/FlineDev/AnyLint/pull/12) | Author: [Cihat Gündüz](https://github.com/Jeehut) 161 | 162 | ## [0.2.0] - 2020-04-10 163 | ### Added 164 | - Added new `-x` / `--xcode` option to print out warnings & errors in an Xcode-compatible manner to improve user experience when used with an Xcode build script. Requires `arguments: CommandLine.arguments` as parameters to `logSummary` in config file. 165 | Issue: [#4](https://github.com/FlineDev/AnyLint/issues/4) | PR: [#8](https://github.com/FlineDev/AnyLint/pull/8) | Author: [Cihat Gündüz](https://github.com/Jeehut) 166 | 167 | ## [0.1.1] - 2020-03-23 168 | ### Added 169 | - Added two simple lint check examples in first code sample in README. (Thanks for the pointer, [Dave Verwer](https://github.com/daveverwer)!) 170 | Author: [Cihat Gündüz](https://github.com/Jeehut) 171 | ### Changed 172 | - Changed `CheckInfo` id casing convention from snake_case to UpperCamelCase in `blank` template. 173 | Author: [Cihat Gündüz](https://github.com/Jeehut) 174 | 175 | ## [0.1.0] - 2020-03-22 176 | Initial public release. 177 | -------------------------------------------------------------------------------- /Tests/AnyLintTests/Checkers/FileContentsCheckerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AnyLint 2 | @testable import Utility 3 | import XCTest 4 | 5 | // swiftlint:disable function_body_length 6 | 7 | final class FileContentsCheckerTests: XCTestCase { 8 | override func setUp() { 9 | log = Logger(outputType: .test) 10 | TestHelper.shared.reset() 11 | } 12 | 13 | func testPerformCheck() { 14 | let temporaryFiles: [TemporaryFile] = [ 15 | (subpath: "Sources/Hello.swift", contents: "let x = 5\nvar y = 10"), 16 | (subpath: "Sources/World.swift", contents: "let x=5\nvar y=10"), 17 | ] 18 | 19 | withTemporaryFiles(temporaryFiles) { filePathsToCheck in 20 | let checkInfo = CheckInfo(id: "Whitespacing", hint: "Always add a single whitespace around '='.", severity: .warning) 21 | let violations = try FileContentsChecker( 22 | checkInfo: checkInfo, 23 | regex: #"(let|var) \w+=\w+"#, 24 | violationLocation: .init(range: .fullMatch, bound: .lower), 25 | filePathsToCheck: filePathsToCheck, 26 | autoCorrectReplacement: nil, 27 | repeatIfAutoCorrected: false 28 | ).performCheck() 29 | 30 | XCTAssertEqual(violations.count, 2) 31 | 32 | XCTAssertEqual(violations[0].checkInfo, checkInfo) 33 | XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/World.swift") 34 | XCTAssertEqual(violations[0].locationInfo!.line, 1) 35 | XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) 36 | 37 | XCTAssertEqual(violations[1].checkInfo, checkInfo) 38 | XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/World.swift") 39 | XCTAssertEqual(violations[1].locationInfo!.line, 2) 40 | XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) 41 | } 42 | } 43 | 44 | func testSkipInFile() { 45 | let temporaryFiles: [TemporaryFile] = [ 46 | (subpath: "Sources/Hello.swift", contents: "// AnyLint.skipInFile: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10"), 47 | (subpath: "Sources/World.swift", contents: "// AnyLint.skipInFile: All\n\n\nlet x=5\nvar y=10"), 48 | (subpath: "Sources/Foo.swift", contents: "// AnyLint.skipInFile: OtherRule\n\n\nlet x=5\nvar y=10"), 49 | ] 50 | 51 | withTemporaryFiles(temporaryFiles) { filePathsToCheck in 52 | let checkInfo = CheckInfo(id: "Whitespacing", hint: "Always add a single whitespace around '='.", severity: .warning) 53 | let violations = try FileContentsChecker( 54 | checkInfo: checkInfo, 55 | regex: #"(let|var) \w+=\w+"#, 56 | violationLocation: .init(range: .fullMatch, bound: .lower), 57 | filePathsToCheck: filePathsToCheck, 58 | autoCorrectReplacement: nil, 59 | repeatIfAutoCorrected: false 60 | ).performCheck() 61 | 62 | XCTAssertEqual(violations.count, 2) 63 | 64 | XCTAssertEqual(violations[0].checkInfo, checkInfo) 65 | XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/Foo.swift") 66 | XCTAssertEqual(violations[0].locationInfo!.line, 4) 67 | XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) 68 | 69 | XCTAssertEqual(violations[1].checkInfo, checkInfo) 70 | XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/Foo.swift") 71 | XCTAssertEqual(violations[1].locationInfo!.line, 5) 72 | XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) 73 | } 74 | } 75 | 76 | func testSkipHere() { 77 | let temporaryFiles: [TemporaryFile] = [ 78 | (subpath: "Sources/Hello.swift", contents: "// AnyLint.skipHere: OtherRule, Whitespacing\n\n\nlet x=5\nvar y=10"), 79 | (subpath: "Sources/World.swift", contents: "\n\n// AnyLint.skipHere: OtherRule, Whitespacing\nlet x=5\nvar y=10"), 80 | (subpath: "Sources/Foo.swift", contents: "\n\n\nlet x=5\nvar y=10 // AnyLint.skipHere: OtherRule, Whitespacing\n"), 81 | (subpath: "Sources/Bar.swift", contents: "\n\n\nlet x=5\nvar y=10\n// AnyLint.skipHere: OtherRule, Whitespacing"), 82 | ] 83 | 84 | withTemporaryFiles(temporaryFiles) { filePathsToCheck in 85 | let checkInfo = CheckInfo(id: "Whitespacing", hint: "Always add a single whitespace around '='.", severity: .warning) 86 | let violations = try FileContentsChecker( 87 | checkInfo: checkInfo, 88 | regex: #"(let|var) \w+=\w+"#, 89 | violationLocation: .init(range: .fullMatch, bound: .lower), 90 | filePathsToCheck: filePathsToCheck, 91 | autoCorrectReplacement: nil, 92 | repeatIfAutoCorrected: false 93 | ).performCheck() 94 | 95 | XCTAssertEqual(violations.count, 6) 96 | 97 | XCTAssertEqual(violations[0].checkInfo, checkInfo) 98 | XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/Hello.swift") 99 | XCTAssertEqual(violations[0].locationInfo!.line, 4) 100 | XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) 101 | 102 | XCTAssertEqual(violations[1].checkInfo, checkInfo) 103 | XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/Hello.swift") 104 | XCTAssertEqual(violations[1].locationInfo!.line, 5) 105 | XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) 106 | 107 | XCTAssertEqual(violations[2].checkInfo, checkInfo) 108 | XCTAssertEqual(violations[2].filePath, "\(tempDir)/Sources/World.swift") 109 | XCTAssertEqual(violations[2].locationInfo!.line, 5) 110 | XCTAssertEqual(violations[2].locationInfo!.charInLine, 1) 111 | 112 | XCTAssertEqual(violations[3].checkInfo, checkInfo) 113 | XCTAssertEqual(violations[3].filePath, "\(tempDir)/Sources/Foo.swift") 114 | XCTAssertEqual(violations[3].locationInfo!.line, 4) 115 | XCTAssertEqual(violations[3].locationInfo!.charInLine, 1) 116 | 117 | XCTAssertEqual(violations[4].checkInfo, checkInfo) 118 | XCTAssertEqual(violations[4].filePath, "\(tempDir)/Sources/Bar.swift") 119 | XCTAssertEqual(violations[4].locationInfo!.line, 4) 120 | XCTAssertEqual(violations[4].locationInfo!.charInLine, 1) 121 | 122 | XCTAssertEqual(violations[5].checkInfo, checkInfo) 123 | XCTAssertEqual(violations[5].filePath, "\(tempDir)/Sources/Bar.swift") 124 | XCTAssertEqual(violations[5].locationInfo!.line, 5) 125 | XCTAssertEqual(violations[5].locationInfo!.charInLine, 1) 126 | } 127 | } 128 | 129 | func testSkipIfEqualsToAutocorrectReplacement() { 130 | let temporaryFiles: [TemporaryFile] = [ 131 | (subpath: "Sources/Hello.swift", contents: "let x = 5\nvar y = 10"), 132 | (subpath: "Sources/World.swift", contents: "let x =5\nvar y= 10"), 133 | ] 134 | 135 | withTemporaryFiles(temporaryFiles) { filePathsToCheck in 136 | let checkInfo = CheckInfo(id: "Whitespacing", hint: "Always add a single whitespace around '='.", severity: .warning) 137 | let violations = try FileContentsChecker( 138 | checkInfo: checkInfo, 139 | regex: #"(let|var) (\w+)\s*=\s*(\w+)"#, 140 | violationLocation: .init(range: .fullMatch, bound: .lower), 141 | filePathsToCheck: filePathsToCheck, 142 | autoCorrectReplacement: "$1 $2 = $3", 143 | repeatIfAutoCorrected: false 144 | ).performCheck() 145 | 146 | XCTAssertEqual(violations.count, 2) 147 | 148 | XCTAssertEqual(violations[0].checkInfo, checkInfo) 149 | XCTAssertEqual(violations[0].filePath, "\(tempDir)/Sources/World.swift") 150 | XCTAssertEqual(violations[0].locationInfo!.line, 1) 151 | XCTAssertEqual(violations[0].locationInfo!.charInLine, 1) 152 | 153 | XCTAssertEqual(violations[1].checkInfo, checkInfo) 154 | XCTAssertEqual(violations[1].filePath, "\(tempDir)/Sources/World.swift") 155 | XCTAssertEqual(violations[1].locationInfo!.line, 2) 156 | XCTAssertEqual(violations[1].locationInfo!.charInLine, 1) 157 | } 158 | } 159 | 160 | func testRepeatIfAutoCorrected() { 161 | let temporaryFiles: [TemporaryFile] = [ 162 | (subpath: "Sources/Hello.swift", contents: "let x = 500\nvar y = 10000"), 163 | (subpath: "Sources/World.swift", contents: "let x = 50000000\nvar y = 100000000000000"), 164 | ] 165 | 166 | withTemporaryFiles(temporaryFiles) { filePathsToCheck in 167 | let checkInfo = CheckInfo(id: "LongNumbers", hint: "Format long numbers with `_` after each triple of digits from the right.", severity: .warning) 168 | let violations = try FileContentsChecker( 169 | checkInfo: checkInfo, 170 | regex: #"(? Bool { 47 | firstMatch(in: string) != nil 48 | } 49 | 50 | /// If the regex matches `string`, returns a `Match` describing the 51 | /// first matched string and any captures. If there are no matches, returns 52 | /// `nil`. 53 | /// 54 | /// - parameter string: The string to match against. 55 | /// 56 | /// - returns: An optional `Match` describing the first match, or `nil`. 57 | public func firstMatch(in string: String) -> Match? { 58 | let firstMatch = regularExpression 59 | .firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) 60 | .map { Match(result: $0, in: string) } 61 | return firstMatch 62 | } 63 | 64 | /// If the regex matches `string`, returns an array of `Match`, describing 65 | /// every match inside `string`. If there are no matches, returns an empty 66 | /// array. 67 | /// 68 | /// - parameter string: The string to match against. 69 | /// 70 | /// - returns: An array of `Match` describing every match in `string`. 71 | public func matches(in string: String) -> [Match] { 72 | let matches = regularExpression 73 | .matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) 74 | .map { Match(result: $0, in: string) } 75 | return matches 76 | } 77 | 78 | // MARK: Replacing 79 | /// Returns a new string where each substring matched by `regex` is replaced 80 | /// with `template`. 81 | /// 82 | /// The template string may be a literal string, or include template variables: 83 | /// the variable `$0` will be replaced with the entire matched substring, `$1` 84 | /// with the first capture group, etc. 85 | /// 86 | /// For example, to include the literal string "$1" in the replacement string, 87 | /// you must escape the "$": `\$1`. 88 | /// 89 | /// - parameters: 90 | /// - regex: A regular expression to match against `self`. 91 | /// - template: A template string used to replace matches. 92 | /// - count: The maximum count of matches to replace, beginning with the first match. 93 | /// 94 | /// - returns: A string with all matches of `regex` replaced by `template`. 95 | public func replacingMatches(in input: String, with template: String, count: Int? = nil) -> String { 96 | var output = input 97 | let matches = self.matches(in: input) 98 | let rangedMatches = Array(matches[0 ..< min(matches.count, count ?? .max)]) 99 | for match in rangedMatches.reversed() { 100 | let replacement = match.string(applyingTemplate: template) 101 | output.replaceSubrange(match.range, with: replacement) 102 | } 103 | 104 | return output 105 | } 106 | } 107 | 108 | // MARK: - CustomStringConvertible 109 | extension Regex: CustomStringConvertible { 110 | /// Returns a string describing the regex using its pattern string. 111 | public var description: String { 112 | "/\(regularExpression.pattern)/\(options)" 113 | } 114 | } 115 | 116 | // MARK: - Equatable 117 | extension Regex: Equatable { 118 | /// Determines the equality of to `Regex`` instances. 119 | /// Two `Regex` are considered equal, if both the pattern string and the options 120 | /// passed on initialization are equal. 121 | public static func == (lhs: Regex, rhs: Regex) -> Bool { 122 | lhs.regularExpression.pattern == rhs.regularExpression.pattern && 123 | lhs.regularExpression.options == rhs.regularExpression.options 124 | } 125 | } 126 | 127 | // MARK: - Hashable 128 | extension Regex: Hashable { 129 | /// Manages hashing of the `Regex` instance. 130 | public func hash(into hasher: inout Hasher) { 131 | hasher.combine(pattern) 132 | hasher.combine(options) 133 | } 134 | } 135 | 136 | // MARK: - Options 137 | extension Regex { 138 | /// `Options` defines alternate behaviours of regular expressions when matching. 139 | public struct Options: OptionSet { 140 | // MARK: - Properties 141 | /// Ignores the case of letters when matching. 142 | public static let ignoreCase = Options(rawValue: 1) 143 | 144 | /// Ignore any metacharacters in the pattern, treating every character as 145 | /// a literal. 146 | public static let ignoreMetacharacters = Options(rawValue: 1 << 1) 147 | 148 | /// By default, "^" matches the beginning of the string and "$" matches the 149 | /// end of the string, ignoring any newlines. With this option, "^" will 150 | /// the beginning of each line, and "$" will match the end of each line. 151 | public static let anchorsMatchLines = Options(rawValue: 1 << 2) 152 | 153 | /// Usually, "." matches all characters except newlines (\n). Using this, 154 | /// options will allow "." to match newLines 155 | public static let dotMatchesLineSeparators = Options(rawValue: 1 << 3) 156 | 157 | /// The raw value of the `OptionSet` 158 | public let rawValue: Int 159 | 160 | /// Transform an instance of `Regex.Options` into the equivalent `NSRegularExpression.Options`. 161 | /// 162 | /// - returns: The equivalent `NSRegularExpression.Options`. 163 | var toNSRegularExpressionOptions: NSRegularExpression.Options { 164 | var options = NSRegularExpression.Options() 165 | if contains(.ignoreCase) { options.insert(.caseInsensitive) } 166 | if contains(.ignoreMetacharacters) { options.insert(.ignoreMetacharacters) } 167 | if contains(.anchorsMatchLines) { options.insert(.anchorsMatchLines) } 168 | if contains(.dotMatchesLineSeparators) { options.insert(.dotMatchesLineSeparators) } 169 | return options 170 | } 171 | 172 | // MARK: - Initializers 173 | /// The raw value init for the `OptionSet` 174 | public init(rawValue: Int) { 175 | self.rawValue = rawValue 176 | } 177 | } 178 | } 179 | 180 | extension Regex.Options: CustomStringConvertible { 181 | public var description: String { 182 | var description = "" 183 | if contains(.ignoreCase) { description += "i" } 184 | if contains(.ignoreMetacharacters) { description += "x" } 185 | if !contains(.anchorsMatchLines) { description += "a" } 186 | if contains(.dotMatchesLineSeparators) { description += "m" } 187 | return description 188 | } 189 | } 190 | 191 | extension Regex.Options: Equatable, Hashable { 192 | public static func == (lhs: Regex.Options, rhs: Regex.Options) -> Bool { 193 | lhs.rawValue == rhs.rawValue 194 | } 195 | 196 | public func hash(into hasher: inout Hasher) { 197 | hasher.combine(rawValue) 198 | } 199 | } 200 | 201 | // MARK: - Match 202 | extension Regex { 203 | /// A `Match` encapsulates the result of a single match in a string, 204 | /// providing access to the matched string, as well as any capture groups within 205 | /// that string. 206 | public class Match: CustomStringConvertible { 207 | // MARK: Properties 208 | /// The entire matched string. 209 | public lazy var string: String = { 210 | String(describing: self.baseString[self.range]) 211 | }() 212 | 213 | /// The range of the matched string. 214 | public lazy var range: Range = { 215 | Range(self.result.range, in: self.baseString)! 216 | }() 217 | 218 | /// The matching string for each capture group in the regular expression 219 | /// (if any). 220 | /// 221 | /// **Note:** Usually if the match was successful, the captures will by 222 | /// definition be non-nil. However if a given capture group is optional, the 223 | /// captured string may also be nil, depending on the particular string that 224 | /// is being matched against. 225 | /// 226 | /// Example: 227 | /// 228 | /// let regex = Regex("(a)?(b)") 229 | /// 230 | /// regex.matches(in: "ab")first?.captures // [Optional("a"), Optional("b")] 231 | /// regex.matches(in: "b").first?.captures // [nil, Optional("b")] 232 | public lazy var captures: [String?] = { 233 | let captureRanges = stride(from: 0, to: result.numberOfRanges, by: 1) 234 | .map(result.range) 235 | .dropFirst() 236 | .map { [unowned self] in 237 | Range($0, in: self.baseString) 238 | } 239 | 240 | return captureRanges.map { [unowned self] captureRange in 241 | guard let captureRange = captureRange else { return nil } 242 | return String(describing: self.baseString[captureRange]) 243 | } 244 | }() 245 | 246 | let result: NSTextCheckingResult 247 | 248 | let baseString: String 249 | 250 | // MARK: - Initializers 251 | internal init(result: NSTextCheckingResult, in string: String) { 252 | precondition( 253 | result.regularExpression != nil, 254 | "NSTextCheckingResult must originate from regular expression parsing." 255 | ) 256 | 257 | self.result = result 258 | self.baseString = string 259 | } 260 | 261 | // MARK: - Methods 262 | /// Returns a new string where the matched string is replaced according to the `template`. 263 | /// 264 | /// The template string may be a literal string, or include template variables: 265 | /// the variable `$0` will be replaced with the entire matched substring, `$1` 266 | /// with the first capture group, etc. 267 | /// 268 | /// For example, to include the literal string "$1" in the replacement string, 269 | /// you must escape the "$": `\$1`. 270 | /// 271 | /// - parameters: 272 | /// - template: The template string used to replace matches. 273 | /// 274 | /// - returns: A string with `template` applied to the matched string. 275 | public func string(applyingTemplate template: String) -> String { 276 | let replacement = result.regularExpression!.replacementString( 277 | for: result, 278 | in: baseString, 279 | offset: 0, 280 | template: template 281 | ) 282 | 283 | return replacement 284 | } 285 | 286 | // MARK: - CustomStringConvertible 287 | /// Returns a string describing the match. 288 | public var description: String { 289 | "Match<\"\(string)\">" 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /Sources/AnyLint/Lint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utility 3 | 4 | /// The linter type providing APIs for checking anything using regular expressions. 5 | public enum Lint { 6 | /// Checks the contents of files. 7 | /// 8 | /// - Parameters: 9 | /// - checkInfo: The info object providing some general information on the lint check. 10 | /// - 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'. 11 | /// - violationlocation: Specifies the position of the violation marker violations should be reported. Can be the `lower` or `upper` end of a `fullMatch` or `captureGroup(index:)`. 12 | /// - matchingExamples: An array of example contents where the `regex` is expected to trigger. Optionally, the expected pointer position can be marked with ↘. 13 | /// - nonMatchingExamples: An array of example contents where the `regex` is expected not to trigger. 14 | /// - includeFilters: An array of regexes defining which files should be incuded in the check. Will check all files matching any of the given regexes. 15 | /// - 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. 16 | /// - autoCorrectReplacement: A replacement string which can reference any capture groups in the `regex` to use for autocorrection. 17 | /// - autoCorrectExamples: An array of example structs with a `before` and an `after` String object to check if autocorrection works properly. 18 | /// - repeatIfAutoCorrected: Repeat check if at least one auto-correction was applied in last run. Defaults to `false`. 19 | public static func checkFileContents( 20 | checkInfo: CheckInfo, 21 | regex: Regex, 22 | violationLocation: ViolationLocationConfig = .init(range: .fullMatch, bound: .lower), 23 | matchingExamples: [String] = [], 24 | nonMatchingExamples: [String] = [], 25 | includeFilters: [Regex] = [#".*"#], 26 | excludeFilters: [Regex] = [], 27 | autoCorrectReplacement: String? = nil, 28 | autoCorrectExamples: [AutoCorrection] = [], 29 | repeatIfAutoCorrected: Bool = false 30 | ) throws { 31 | if !Options.unvalidated { 32 | validate(regex: regex, matchesForEach: matchingExamples, checkInfo: checkInfo) 33 | validate(regex: regex, doesNotMatchAny: nonMatchingExamples, checkInfo: checkInfo) 34 | 35 | validateParameterCombinations( 36 | checkInfo: checkInfo, 37 | autoCorrectReplacement: autoCorrectReplacement, 38 | autoCorrectExamples: autoCorrectExamples, 39 | violateIfNoMatchesFound: nil 40 | ) 41 | 42 | if let autoCorrectReplacement = autoCorrectReplacement { 43 | validateAutocorrectsAll( 44 | checkInfo: checkInfo, 45 | examples: autoCorrectExamples, 46 | regex: regex, 47 | autocorrectReplacement: autoCorrectReplacement 48 | ) 49 | } 50 | } 51 | 52 | guard !Options.validateOnly else { 53 | Statistics.shared.executedChecks.append(checkInfo) 54 | return 55 | } 56 | 57 | let filePathsToCheck: [String] = FilesSearch.shared.allFiles( 58 | within: fileManager.currentDirectoryPath, 59 | includeFilters: includeFilters, 60 | excludeFilters: excludeFilters 61 | ) 62 | 63 | try Statistics.shared.measureTime(check: checkInfo) { 64 | let violations = try FileContentsChecker( 65 | checkInfo: checkInfo, 66 | regex: regex, 67 | violationLocation: violationLocation, 68 | filePathsToCheck: filePathsToCheck, 69 | autoCorrectReplacement: autoCorrectReplacement, 70 | repeatIfAutoCorrected: repeatIfAutoCorrected 71 | ).performCheck() 72 | 73 | Statistics.shared.found(violations: violations, in: checkInfo) 74 | } 75 | } 76 | 77 | /// Checks the names of files. 78 | /// 79 | /// - Parameters: 80 | /// - checkInfo: The info object providing some general information on the lint check. 81 | /// - 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'. 82 | /// - matchingExamples: An array of example paths where the `regex` is expected to trigger. Optionally, the expected pointer position can be marked with ↘. 83 | /// - nonMatchingExamples: An array of example paths where the `regex` is expected not to trigger. 84 | /// - includeFilters: Defines which files should be incuded in check. Checks all files matching any of the given regexes. 85 | /// - excludeFilters: Defines which files should be excluded from check. Ignores all files matching any of the given regexes. Takes precedence over includes. 86 | /// - autoCorrectReplacement: A replacement string which can reference any capture groups in the `regex` to use for autocorrection. 87 | /// - autoCorrectExamples: An array of example structs with a `before` and an `after` String object to check if autocorrection works properly. 88 | /// - violateIfNoMatchesFound: Inverts the violation logic to report a single violation if no matches are found instead of reporting a violation for each match. 89 | public static func checkFilePaths( 90 | checkInfo: CheckInfo, 91 | regex: Regex, 92 | matchingExamples: [String] = [], 93 | nonMatchingExamples: [String] = [], 94 | includeFilters: [Regex] = [#".*"#], 95 | excludeFilters: [Regex] = [], 96 | autoCorrectReplacement: String? = nil, 97 | autoCorrectExamples: [AutoCorrection] = [], 98 | violateIfNoMatchesFound: Bool = false 99 | ) throws { 100 | if !Options.unvalidated { 101 | validate(regex: regex, matchesForEach: matchingExamples, checkInfo: checkInfo) 102 | validate(regex: regex, doesNotMatchAny: nonMatchingExamples, checkInfo: checkInfo) 103 | validateParameterCombinations( 104 | checkInfo: checkInfo, 105 | autoCorrectReplacement: autoCorrectReplacement, 106 | autoCorrectExamples: autoCorrectExamples, 107 | violateIfNoMatchesFound: violateIfNoMatchesFound 108 | ) 109 | 110 | if let autoCorrectReplacement = autoCorrectReplacement { 111 | validateAutocorrectsAll( 112 | checkInfo: checkInfo, 113 | examples: autoCorrectExamples, 114 | regex: regex, 115 | autocorrectReplacement: autoCorrectReplacement 116 | ) 117 | } 118 | } 119 | 120 | guard !Options.validateOnly else { 121 | Statistics.shared.executedChecks.append(checkInfo) 122 | return 123 | } 124 | 125 | let filePathsToCheck: [String] = FilesSearch.shared.allFiles( 126 | within: fileManager.currentDirectoryPath, 127 | includeFilters: includeFilters, 128 | excludeFilters: excludeFilters 129 | ) 130 | 131 | try Statistics.shared.measureTime(check: checkInfo) { 132 | let violations = try FilePathsChecker( 133 | checkInfo: checkInfo, 134 | regex: regex, 135 | filePathsToCheck: filePathsToCheck, 136 | autoCorrectReplacement: autoCorrectReplacement, 137 | violateIfNoMatchesFound: violateIfNoMatchesFound 138 | ).performCheck() 139 | 140 | Statistics.shared.found(violations: violations, in: checkInfo) 141 | } 142 | } 143 | 144 | /// Run custom logic as checks. 145 | /// 146 | /// - Parameters: 147 | /// - checkInfo: The info object providing some general information on the lint check. 148 | /// - customClosure: The custom logic to run which produces an array of `Violation` objects for any violations. 149 | public static func customCheck(checkInfo: CheckInfo, customClosure: (CheckInfo) throws -> [Violation]) rethrows { 150 | try Statistics.shared.measureTime(check: checkInfo) { 151 | guard !Options.validateOnly else { 152 | Statistics.shared.executedChecks.append(checkInfo) 153 | return 154 | } 155 | 156 | Statistics.shared.found(violations: try customClosure(checkInfo), in: checkInfo) 157 | } 158 | } 159 | 160 | /// Logs the summary of all detected violations and exits successfully on no violations or with a failure, if any violations. 161 | public static func logSummaryAndExit(arguments: [String] = [], afterPerformingChecks checksToPerform: () throws -> Void = {}) throws { 162 | let failOnWarnings = arguments.contains(Constants.strictArgument) 163 | let targetIsXcode = arguments.contains(Logger.OutputType.xcode.rawValue) 164 | let measure = arguments.contains(Constants.measureArgument) 165 | 166 | if targetIsXcode { 167 | log = Logger(outputType: .xcode) 168 | } 169 | 170 | log.logDebugLevel = arguments.contains(Constants.debugArgument) 171 | Options.validateOnly = arguments.contains(Constants.validateArgument) 172 | 173 | try checksToPerform() 174 | 175 | guard !Options.validateOnly else { 176 | Statistics.shared.logValidationSummary() 177 | log.exit(status: .success) 178 | return // only reachable in unit tests 179 | } 180 | 181 | Statistics.shared.logCheckSummary(printExecutionTime: measure) 182 | 183 | if Statistics.shared.violations(severity: .error, excludeAutocorrected: targetIsXcode).isFilled { 184 | log.exit(status: .failure) 185 | } else if failOnWarnings && Statistics.shared.violations(severity: .warning, excludeAutocorrected: targetIsXcode).isFilled { 186 | log.exit(status: .failure) 187 | } else { 188 | log.exit(status: .success) 189 | } 190 | } 191 | 192 | static func validate(regex: Regex, matchesForEach matchingExamples: [String], checkInfo: CheckInfo) { 193 | if matchingExamples.isFilled { 194 | log.message("Validating 'matchingExamples' for \(checkInfo) ...", level: .debug) 195 | } 196 | 197 | for example in matchingExamples where !regex.matches(example) { 198 | log.message( 199 | "Couldn't find a match for regex \(regex) in check '\(checkInfo.id)' within matching example:\n\(example)", 200 | level: .error 201 | ) 202 | log.exit(status: .failure) 203 | } 204 | } 205 | 206 | static func validate(regex: Regex, doesNotMatchAny nonMatchingExamples: [String], checkInfo: CheckInfo) { 207 | if nonMatchingExamples.isFilled { 208 | log.message("Validating 'nonMatchingExamples' for \(checkInfo) ...", level: .debug) 209 | } 210 | 211 | for example in nonMatchingExamples where regex.matches(example) { 212 | log.message( 213 | "Unexpectedly found a match for regex \(regex) in check '\(checkInfo.id)' within non-matching example:\n\(example)", 214 | level: .error 215 | ) 216 | log.exit(status: .failure) 217 | } 218 | } 219 | 220 | static func validateAutocorrectsAll(checkInfo: CheckInfo, examples: [AutoCorrection], regex: Regex, autocorrectReplacement: String) { 221 | if examples.isFilled { 222 | log.message("Validating 'autoCorrectExamples' for \(checkInfo) ...", level: .debug) 223 | } 224 | 225 | for autocorrect in examples { 226 | let autocorrected = regex.replaceAllCaptures(in: autocorrect.before, with: autocorrectReplacement) 227 | if autocorrected != autocorrect.after { 228 | log.message( 229 | """ 230 | Autocorrecting example for \(checkInfo.id) did not result in expected output. 231 | Before: '\(autocorrect.before.showWhitespacesAndNewlines())' 232 | After: '\(autocorrected.showWhitespacesAndNewlines())' 233 | Expected: '\(autocorrect.after.showWhitespacesAndNewlines())' 234 | """, 235 | level: .error 236 | ) 237 | log.exit(status: .failure) 238 | } 239 | } 240 | } 241 | 242 | static func validateParameterCombinations( 243 | checkInfo: CheckInfo, 244 | autoCorrectReplacement: String?, 245 | autoCorrectExamples: [AutoCorrection], 246 | violateIfNoMatchesFound: Bool? 247 | ) { 248 | if autoCorrectExamples.isFilled && autoCorrectReplacement == nil { 249 | log.message( 250 | "`autoCorrectExamples` provided for check \(checkInfo.id) without specifying an `autoCorrectReplacement`.", 251 | level: .warning 252 | ) 253 | } 254 | 255 | guard autoCorrectReplacement == nil || violateIfNoMatchesFound != true else { 256 | log.message( 257 | "Incompatible options specified for check \(checkInfo.id): autoCorrectReplacement and violateIfNoMatchesFound can't be used together.", 258 | level: .error 259 | ) 260 | log.exit(status: .failure) 261 | return // only reachable in unit tests 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /lint.swift: -------------------------------------------------------------------------------- 1 | #!/opt/homebrew/bin/swift-sh 2 | import AnyLint // . 3 | import Utility 4 | import ShellOut // @JohnSundell 5 | 6 | try Lint.logSummaryAndExit(arguments: CommandLine.arguments) { 7 | // MARK: - Variables 8 | let swiftSourceFiles: Regex = #"Sources/.*\.swift"# 9 | let swiftTestFiles: Regex = #"Tests/.*\.swift"# 10 | let readmeFile: Regex = #"README\.md"# 11 | let changelogFile: Regex = #"^CHANGELOG\.md$"# 12 | let projectName: String = "AnyLint" 13 | 14 | // MARK: - Checks 15 | // MARK: Changelog 16 | try Lint.checkFilePaths( 17 | checkInfo: "Changelog: Each project should have a CHANGELOG.md file, tracking the changes within a project over time.", 18 | regex: changelogFile, 19 | matchingExamples: ["CHANGELOG.md"], 20 | nonMatchingExamples: ["CHANGELOG.markdown", "Changelog.md", "ChangeLog.md"], 21 | violateIfNoMatchesFound: true 22 | ) 23 | 24 | // MARK: ChangelogEntryTrailingWhitespaces 25 | try Lint.checkFileContents( 26 | checkInfo: "ChangelogEntryTrailingWhitespaces: The summary line of a Changelog entry should end with two whitespaces.", 27 | regex: #"\n([-–] (?!None\.).*[^ ])( {0,1}| {3,})\n"#, 28 | matchingExamples: ["\n- Fixed a bug.\n Issue:", "\n- Added a new option. (see [Link](#)) \nPR:"], 29 | nonMatchingExamples: ["\n- Fixed a bug. \n Issue:", "\n- Added a new option. (see [Link](#)) \nPR:"], 30 | includeFilters: [changelogFile], 31 | autoCorrectReplacement: "\n$1 \n", 32 | autoCorrectExamples: [ 33 | ["before": "\n- Fixed a bug.\n Issue:", "after": "\n- Fixed a bug. \n Issue:"], 34 | ["before": "\n- Fixed a bug. \n Issue:", "after": "\n- Fixed a bug. \n Issue:"], 35 | ["before": "\n- Fixed a bug. \n Issue:", "after": "\n- Fixed a bug. \n Issue:"], 36 | ["before": "\n- Fixed a bug !\n Issue:", "after": "\n- Fixed a bug ! \n Issue:"], 37 | ["before": "\n- Fixed a bug ! \n Issue:", "after": "\n- Fixed a bug ! \n Issue:"], 38 | ["before": "\n- Fixed a bug ! \n Issue:", "after": "\n- Fixed a bug ! \n Issue:"], 39 | ] 40 | ) 41 | 42 | // MARK: ChangelogEntryLeadingWhitespaces 43 | try Lint.checkFileContents( 44 | checkInfo: "ChangelogEntryLeadingWhitespaces: The links line of a Changelog entry should start with two whitespaces.", 45 | regex: #"\n( {0,1}| {3,})(Tasks?:|Issues?:|PRs?:|Authors?:)"#, 46 | matchingExamples: ["\n- Fixed a bug.\nIssue: [Link](#)", "\n- Fixed a bug. \nIssue: [Link](#)", "\n- Fixed a bug. \nIssue: [Link](#)"], 47 | nonMatchingExamples: ["- Fixed a bug.\n Issue: [Link](#)"], 48 | includeFilters: [changelogFile], 49 | autoCorrectReplacement: "\n $2", 50 | autoCorrectExamples: [ 51 | ["before": "\n- Fixed a bug.\nIssue: [Link](#)", "after": "\n- Fixed a bug.\n Issue: [Link](#)"], 52 | ["before": "\n- Fixed a bug.\n Issue: [Link](#)", "after": "\n- Fixed a bug.\n Issue: [Link](#)"], 53 | ["before": "\n- Fixed a bug.\n Issue: [Link](#)", "after": "\n- Fixed a bug.\n Issue: [Link](#)"], 54 | ] 55 | ) 56 | 57 | // MARK: EmptyMethodBody 58 | try Lint.checkFileContents( 59 | checkInfo: "EmptyMethodBody: Don't use whitespace or newlines for the body of empty methods.", 60 | regex: ["declaration": #"(init|func [^\(\s]+)\([^{}]*\)"#, "spacing": #"\s*"#, "body": #"\{\s+\}"#], 61 | matchingExamples: [ 62 | "init() { }", 63 | "init() {\n\n}", 64 | "init(\n x: Int,\n y: Int\n) { }", 65 | "func foo2bar() { }", 66 | "func foo2bar(x: Int, y: Int) { }", 67 | "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", 68 | ], 69 | nonMatchingExamples: ["init() { /* comment */ }", "init() {}", "func foo2bar() {}", "func foo2bar(x: Int, y: Int) {}"], 70 | includeFilters: [swiftSourceFiles, swiftTestFiles], 71 | autoCorrectReplacement: "$declaration {}", 72 | autoCorrectExamples: [ 73 | ["before": "init() { }", "after": "init() {}"], 74 | ["before": "init(x: Int, y: Int) { }", "after": "init(x: Int, y: Int) {}"], 75 | ["before": "init()\n{\n \n}", "after": "init() {}"], 76 | ["before": "init(\n x: Int,\n y: Int\n) {\n \n}", "after": "init(\n x: Int,\n y: Int\n) {}"], 77 | ["before": "func foo2bar() { }", "after": "func foo2bar() {}"], 78 | ["before": "func foo2bar(x: Int, y: Int) { }", "after": "func foo2bar(x: Int, y: Int) {}"], 79 | ["before": "func foo2bar()\n{\n \n}", "after": "func foo2bar() {}"], 80 | ["before": "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", "after": "func foo2bar(\n x: Int,\n y: Int\n) {}"], 81 | ] 82 | ) 83 | 84 | // MARK: EmptyTodo 85 | try Lint.checkFileContents( 86 | checkInfo: "EmptyTodo: `// TODO:` comments should not be empty.", 87 | regex: #"// TODO: ?(\[[\d\-_a-z]+\])? *\n"#, 88 | matchingExamples: ["// TODO:\n", "// TODO: [2020-03-19]\n", "// TODO: [cg_2020-03-19] \n"], 89 | nonMatchingExamples: ["// TODO: refactor", "// TODO: not yet implemented", "// TODO: [cg_2020-03-19] not yet implemented"], 90 | includeFilters: [swiftSourceFiles, swiftTestFiles] 91 | ) 92 | 93 | // MARK: EmptyType 94 | try Lint.checkFileContents( 95 | checkInfo: "EmptyType: Don't keep empty types in code without commenting inside why they are needed.", 96 | regex: #"(class|protocol|struct|enum) [^\{]+\{\s*\}"#, 97 | matchingExamples: ["class Foo {}", "enum Constants {\n \n}", "struct MyViewModel(x: Int, y: Int, closure: () -> Void) {}"], 98 | nonMatchingExamples: ["class Foo { /* TODO: not yet implemented */ }", "func foo() {}", "init() {}", "enum Bar { case x, y }"], 99 | includeFilters: [swiftSourceFiles, swiftTestFiles] 100 | ) 101 | 102 | // MARK: GuardMultiline2 103 | try Lint.checkFileContents( 104 | checkInfo: "GuardMultiline2: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", 105 | regex: [ 106 | "newline": #"\n"#, 107 | "guardIndent": #" *"#, 108 | "guard": #"guard *"#, 109 | "line1": #"[^\n]+,"#, 110 | "line1Indent": #"\n *"#, 111 | "line2": #"[^\n]*\S"#, 112 | "else": #"\s*else\s*\{\s*"# 113 | ], 114 | matchingExamples: [ 115 | """ 116 | 117 | guard let x1 = y1?.imagePath, 118 | let z = EnumType(rawValue: 15) else { 119 | return 2 120 | } 121 | """ 122 | ], 123 | nonMatchingExamples: [ 124 | """ 125 | 126 | guard 127 | let x1 = y1?.imagePath, 128 | let z = EnumType(rawValue: 15) 129 | else { 130 | return 2 131 | } 132 | """, 133 | """ 134 | 135 | guard let url = URL(string: self, relativeTo: fileManager.currentDirectoryUrl) else { 136 | return 2 137 | } 138 | """, 139 | ], 140 | includeFilters: [swiftSourceFiles, swiftTestFiles], 141 | autoCorrectReplacement: """ 142 | 143 | $guardIndentguard 144 | $guardIndent $line1 145 | $guardIndent $line2 146 | $guardIndentelse { 147 | $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} 148 | """, 149 | autoCorrectExamples: [ 150 | [ 151 | "before": """ 152 | let x = 15 153 | guard let x1 = y1?.imagePath, 154 | let z = EnumType(rawValue: 15) else { 155 | return 2 156 | } 157 | """, 158 | "after": """ 159 | let x = 15 160 | guard 161 | let x1 = y1?.imagePath, 162 | let z = EnumType(rawValue: 15) 163 | else { 164 | return 2 165 | } 166 | """ 167 | ], 168 | ] 169 | ) 170 | 171 | // MARK: GuardMultiline3 172 | try Lint.checkFileContents( 173 | checkInfo: "GuardMultiline3: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", 174 | regex: [ 175 | "newline": #"\n"#, 176 | "guardIndent": #" *"#, 177 | "guard": #"guard *"#, 178 | "line1": #"[^\n]+,"#, 179 | "line1Indent": #"\n *"#, 180 | "line2": #"[^\n]+,"#, 181 | "line2Indent": #"\n *"#, 182 | "line3": #"[^\n]*\S"#, 183 | "else": #"\s*else\s*\{\s*"# 184 | ], 185 | matchingExamples: [ 186 | """ 187 | 188 | guard let x1 = y1?.imagePath, 189 | let x2 = y2?.imagePath, 190 | let z = EnumType(rawValue: 15) else { 191 | return 2 192 | } 193 | """ 194 | ], 195 | nonMatchingExamples: [ 196 | """ 197 | 198 | guard 199 | let x1 = y1?.imagePath, 200 | let x2 = y2?.imagePath, 201 | let z = EnumType(rawValue: 15) 202 | else { 203 | return 2 204 | } 205 | """, 206 | """ 207 | 208 | guard let url = URL(x: 1, y: 2, relativeTo: fileManager.currentDirectoryUrl) else { 209 | return 2 210 | } 211 | """, 212 | ], 213 | includeFilters: [swiftSourceFiles, swiftTestFiles], 214 | autoCorrectReplacement: """ 215 | 216 | $guardIndentguard 217 | $guardIndent $line1 218 | $guardIndent $line2 219 | $guardIndent $line3 220 | $guardIndentelse { 221 | $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} 222 | """, 223 | autoCorrectExamples: [ 224 | [ 225 | "before": """ 226 | let x = 15 227 | guard let x1 = y1?.imagePath, 228 | let x2 = y2?.imagePath, 229 | let z = EnumType(rawValue: 15) else { 230 | return 2 231 | } 232 | """, 233 | "after": """ 234 | let x = 15 235 | guard 236 | let x1 = y1?.imagePath, 237 | let x2 = y2?.imagePath, 238 | let z = EnumType(rawValue: 15) 239 | else { 240 | return 2 241 | } 242 | """ 243 | ], 244 | ] 245 | ) 246 | 247 | // MARK: GuardMultiline4 248 | try Lint.checkFileContents( 249 | checkInfo: "GuardMultiline4: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", 250 | regex: [ 251 | "newline": #"\n"#, 252 | "guardIndent": #" *"#, 253 | "guard": #"guard *"#, 254 | "line1": #"[^\n]+,"#, 255 | "line1Indent": #"\n *"#, 256 | "line2": #"[^\n]+,"#, 257 | "line2Indent": #"\n *"#, 258 | "line3": #"[^\n]+,"#, 259 | "line3Indent": #"\n *"#, 260 | "line4": #"[^\n]*\S"#, 261 | "else": #"\s*else\s*\{\s*"# 262 | ], 263 | matchingExamples: [ 264 | """ 265 | 266 | guard let x1 = y1?.imagePath, 267 | let x2 = y2?.imagePath, 268 | let x3 = y3?.imagePath, 269 | let z = EnumType(rawValue: 15) else { 270 | return 2 271 | } 272 | """ 273 | ], 274 | nonMatchingExamples: [ 275 | """ 276 | 277 | guard 278 | let x1 = y1?.imagePath, 279 | let x2 = y2?.imagePath, 280 | let x3 = y3?.imagePath, 281 | let z = EnumType(rawValue: 15) 282 | else { 283 | return 2 284 | } 285 | """, 286 | """ 287 | 288 | guard let url = URL(x: 1, y: 2, z: 3, relativeTo: fileManager.currentDirectoryUrl) else { 289 | return 2 290 | } 291 | """, 292 | ], 293 | includeFilters: [swiftSourceFiles, swiftTestFiles], 294 | autoCorrectReplacement: """ 295 | 296 | $guardIndentguard 297 | $guardIndent $line1 298 | $guardIndent $line2 299 | $guardIndent $line3 300 | $guardIndent $line4 301 | $guardIndentelse { 302 | $guardIndent\u{0020}\u{0020}\u{0020}\u{0020} 303 | """, 304 | autoCorrectExamples: [ 305 | [ 306 | "before": """ 307 | let x = 15 308 | guard let x1 = y1?.imagePath, 309 | let x2 = y2?.imagePath, 310 | let x3 = y3?.imagePath, 311 | let z = EnumType(rawValue: 15) else { 312 | return 2 313 | } 314 | """, 315 | "after": """ 316 | let x = 15 317 | guard 318 | let x1 = y1?.imagePath, 319 | let x2 = y2?.imagePath, 320 | let x3 = y3?.imagePath, 321 | let z = EnumType(rawValue: 15) 322 | else { 323 | return 2 324 | } 325 | """ 326 | ], 327 | ] 328 | ) 329 | 330 | // MARK: GuardMultilineN 331 | try Lint.checkFileContents( 332 | checkInfo: "GuardMultilineN: Close a multiline guard via `else {` on a new line indented like the opening `guard`.", 333 | regex: #"\n *guard *([^\n]+,\n){4,}[^\n]*\S\s*else\s*\{\s*"#, 334 | matchingExamples: [ 335 | """ 336 | 337 | guard let x1 = y1?.imagePath, 338 | let x2 = y2?.imagePath, 339 | let x3 = y3?.imagePath, 340 | let x4 = y4?.imagePath, 341 | let x5 = y5?.imagePath, 342 | let z = EnumType(rawValue: 15) else { 343 | return 2 344 | } 345 | """ 346 | ], 347 | nonMatchingExamples: [ 348 | """ 349 | 350 | guard 351 | let x1 = y1?.imagePath, 352 | let x2 = y2?.imagePath, 353 | let x3 = y3?.imagePath, 354 | let x4 = y4?.imagePath, 355 | let x5 = y5?.imagePath, 356 | let z = EnumType(rawValue: 15) 357 | else { 358 | return 2 359 | } 360 | """, 361 | """ 362 | 363 | guard let url = URL(x1: 1, x2: 2, x3: 3, x4: 4, x5: 5, relativeTo: fileManager.currentDirectoryUrl) else { 364 | return 2 365 | } 366 | """, 367 | ], 368 | includeFilters: [swiftSourceFiles, swiftTestFiles] 369 | ) 370 | 371 | // MARK: IfAsGuard 372 | try Lint.checkFileContents( 373 | checkInfo: "IfAsGuard: Don't use an if statement to just return – use guard for such cases instead.", 374 | regex: #" +if [^\{]+\{\s*return\s*[^\}]*\}(?! *else)"#, 375 | 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) }"], 376 | 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 {"], 377 | includeFilters: [swiftSourceFiles, swiftTestFiles] 378 | ) 379 | 380 | // MARK: LateForceUnwrapping3 381 | try Lint.checkFileContents( 382 | checkInfo: "LateForceUnwrapping3: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", 383 | regex: [ 384 | "openingBrace": #"\("#, 385 | "callPart1": #"[^\s\?\.]+"#, 386 | "separator1": #"\?\."#, 387 | "callPart2": #"[^\s\?\.]+"#, 388 | "separator2": #"\?\."#, 389 | "callPart3": #"[^\s\?\.]+"#, 390 | "separator3": #"\?\."#, 391 | "callPart4": #"[^\s\?\.]+"#, 392 | "closingBraceUnwrap": #"\)!"#, 393 | ], 394 | matchingExamples: ["let x = (viewModel?.user?.profile?.imagePath)!\n"], 395 | nonMatchingExamples: ["call(x: (viewModel?.username)!)", "let x = viewModel!.user!.profile!.imagePath\n"], 396 | includeFilters: [swiftSourceFiles, swiftTestFiles], 397 | autoCorrectReplacement: "$callPart1!.$callPart2!.$callPart3!.$callPart4", 398 | autoCorrectExamples: [ 399 | ["before": "let x = (viewModel?.user?.profile?.imagePath)!\n", "after": "let x = viewModel!.user!.profile!.imagePath\n"], 400 | ] 401 | ) 402 | 403 | // MARK: LateForceUnwrapping2 404 | try Lint.checkFileContents( 405 | checkInfo: "LateForceUnwrapping2: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", 406 | regex: [ 407 | "openingBrace": #"\("#, 408 | "callPart1": #"[^\s\?\.]+"#, 409 | "separator1": #"\?\."#, 410 | "callPart2": #"[^\s\?\.]+"#, 411 | "separator2": #"\?\."#, 412 | "callPart3": #"[^\s\?\.]+"#, 413 | "closingBraceUnwrap": #"\)!"#, 414 | ], 415 | matchingExamples: ["call(x: (viewModel?.profile?.username)!)"], 416 | nonMatchingExamples: ["let x = (viewModel?.user?.profile?.imagePath)!\n", "let x = viewModel!.profile!.imagePath\n"], 417 | includeFilters: [swiftSourceFiles, swiftTestFiles], 418 | autoCorrectReplacement: "$callPart1!.$callPart2!.$callPart3", 419 | autoCorrectExamples: [ 420 | ["before": "let x = (viewModel?.profile?.imagePath)!\n", "after": "let x = viewModel!.profile!.imagePath\n"], 421 | ] 422 | ) 423 | 424 | // MARK: LateForceUnwrapping1 425 | try Lint.checkFileContents( 426 | checkInfo: "LateForceUnwrapping1: Don't use ? first to force unwrap later – directly unwrap within the parantheses.", 427 | regex: [ 428 | "openingBrace": #"\("#, 429 | "callPart1": #"[^\s\?\.]+"#, 430 | "separator1": #"\?\."#, 431 | "callPart2": #"[^\s\?\.]+"#, 432 | "closingBraceUnwrap": #"\)!"#, 433 | ], 434 | matchingExamples: ["call(x: (viewModel?.username)!)"], 435 | nonMatchingExamples: ["call(x: (viewModel?.profile?.username)!)", "call(x: viewModel!.username)"], 436 | includeFilters: [swiftSourceFiles, swiftTestFiles], 437 | autoCorrectReplacement: "$callPart1!.$callPart2", 438 | autoCorrectExamples: [ 439 | ["before": "call(x: (viewModel?.username)!)", "after": "call(x: viewModel!.username)"], 440 | ] 441 | ) 442 | 443 | // MARK: LinuxMainUpToDate 444 | try Lint.customCheck(checkInfo: "LinuxMainUpToDate: The tests in Tests/LinuxMain.swift should be up-to-date.") { checkInfo in 445 | var violations: [Violation] = [] 446 | 447 | let linuxMainFilePath = "Tests/LinuxMain.swift" 448 | let linuxMainContentsBeforeRegeneration = try! String(contentsOfFile: linuxMainFilePath) 449 | 450 | let sourceryDirPath = ".sourcery" 451 | let testsDirPath = "Tests/\(projectName)Tests" 452 | let stencilFilePath = "\(sourceryDirPath)/LinuxMain.stencil" 453 | let generatedLinuxMainFilePath = "\(sourceryDirPath)/LinuxMain.generated.swift" 454 | 455 | let sourceryInstallPath = try? shellOut(to: "which", arguments: ["sourcery"]) 456 | guard sourceryInstallPath != nil else { 457 | log.message( 458 | "Skipped custom check \(checkInfo) – requires Sourcery to be installed, download from: https://github.com/krzysztofzablocki/Sourcery", 459 | level: .warning 460 | ) 461 | return [] 462 | } 463 | 464 | try! shellOut(to: "sourcery", arguments: ["--sources", testsDirPath, "--templates", stencilFilePath, "--output", sourceryDirPath]) 465 | let linuxMainContentsAfterRegeneration = try! String(contentsOfFile: generatedLinuxMainFilePath) 466 | 467 | // move generated file to LinuxMain path to update its contents 468 | try! shellOut(to: "mv", arguments: [generatedLinuxMainFilePath, linuxMainFilePath]) 469 | 470 | if linuxMainContentsBeforeRegeneration != linuxMainContentsAfterRegeneration { 471 | violations.append( 472 | Violation( 473 | checkInfo: checkInfo, 474 | filePath: linuxMainFilePath, 475 | appliedAutoCorrection: AutoCorrection( 476 | before: linuxMainContentsBeforeRegeneration, 477 | after: linuxMainContentsAfterRegeneration 478 | ) 479 | ) 480 | ) 481 | } 482 | 483 | return violations 484 | } 485 | 486 | // MARK: Logger 487 | try Lint.checkFileContents( 488 | checkInfo: "Logger: Don't use `print` – use `log.message` instead.", 489 | regex: #"print\([^\n]+\)"#, 490 | matchingExamples: [#"print("Hellow World!")"#, #"print(5)"#, #"print(\n "hi"\n)"#], 491 | nonMatchingExamples: [#"log.message("Hello world!")"#], 492 | includeFilters: [swiftSourceFiles, swiftTestFiles], 493 | excludeFilters: [#"Sources/.*/Logger\.swift"#] 494 | ) 495 | 496 | // MARK: Readme 497 | try Lint.checkFilePaths( 498 | checkInfo: "Readme: Each project should have a README.md file, explaining how to use or contribute to the project.", 499 | regex: #"^README\.md$"#, 500 | matchingExamples: ["README.md"], 501 | nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], 502 | violateIfNoMatchesFound: true 503 | ) 504 | 505 | // MARK: ReadmePath 506 | try Lint.checkFilePaths( 507 | checkInfo: "ReadmePath: The README file should be named exactly `README.md`.", 508 | regex: #"^(.*/)?([Rr][Ee][Aa][Dd][Mm][Ee]\.markdown|readme\.md|Readme\.md|ReadMe\.md)$"#, 509 | matchingExamples: ["README.markdown", "readme.md", "ReadMe.md"], 510 | nonMatchingExamples: ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "api/help.md"], 511 | autoCorrectReplacement: "$1README.md", 512 | autoCorrectExamples: [ 513 | ["before": "api/readme.md", "after": "api/README.md"], 514 | ["before": "ReadMe.md", "after": "README.md"], 515 | ["before": "README.markdown", "after": "README.md"], 516 | ] 517 | ) 518 | } 519 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

7 | 8 | CI 10 | 11 | 12 | Code Quality 14 | 15 | 16 | Coverage 18 | 19 | 20 | Version: 0.11.0 22 | 23 | 24 | License: MIT 26 | 27 |
28 | 29 | PayPal: Donate 31 | 32 | 33 | GitHub: Become a sponsor 35 | 36 | 37 | Patreon: Become a patron 39 | 40 |

41 | 42 |

43 | Installation 44 | • Getting Started 45 | • Configuration 46 | • Xcode Build Script 47 | • Donation 48 | • Issues 49 | • Regex Cheat Sheet 50 | • License 51 |

52 | 53 | # AnyLint 54 | 55 | 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! 🎉 56 | 57 | ## Installation 58 | 59 | ### Via [Homebrew](https://brew.sh): 60 | 61 | To **install** AnyLint the first time, run these commands: 62 | 63 | ```bash 64 | brew tap FlineDev/AnyLint https://github.com/FlineDev/AnyLint.git 65 | brew install anylint 66 | ``` 67 | 68 | To **update** it to the latest version, run this instead: 69 | 70 | ```bash 71 | brew upgrade anylint 72 | ``` 73 | 74 | ### Via [Mint](https://github.com/yonaskolb/Mint): 75 | 76 | To **install** AnyLint or **update** to the latest version, run this command: 77 | 78 | ```bash 79 | mint install FlineDev/AnyLint 80 | ``` 81 | 82 | ## Getting Started 83 | 84 | To initialize AnyLint in a project, run: 85 | 86 | ```bash 87 | anylint --init blank 88 | ``` 89 | 90 | This will create the Swift script file `lint.swift` with something like the following contents: 91 | 92 | ```swift 93 | #!/opt/local/bin/swift-sh 94 | import AnyLint // @FlineDev 95 | 96 | Lint.logSummaryAndExit(arguments: CommandLine.arguments) { 97 | // MARK: - Variables 98 | let readmeFile: Regex = #"README\.md"# 99 | 100 | // MARK: - Checks 101 | // MARK: Readme 102 | try Lint.checkFilePaths( 103 | checkInfo: "Readme: Each project should have a README.md file explaining the project.", 104 | regex: readmeFile, 105 | matchingExamples: ["README.md"], 106 | nonMatchingExamples: ["README.markdown", "Readme.md", "ReadMe.md"], 107 | violateIfNoMatchesFound: true 108 | ) 109 | 110 | // MARK: ReadmeTypoLicense 111 | try Lint.checkFileContents( 112 | checkInfo: "ReadmeTypoLicense: Misspelled word 'license'.", 113 | regex: #"([\s#]L|l)isence([\s\.,:;])"#, 114 | matchingExamples: [" license:", "## Lisence\n"], 115 | nonMatchingExamples: [" license:", "## License\n"], 116 | includeFilters: [readmeFile], 117 | autoCorrectReplacement: "$1icense$2", 118 | autoCorrectExamples: [ 119 | ["before": " lisence:", "after": " license:"], 120 | ["before": "## Lisence\n", "after": "## License\n"], 121 | ] 122 | ) 123 | } 124 | 125 | ``` 126 | 127 | The most important thing to note is that the **first three lines are required** for AnyLint to work properly. 128 | 129 | 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. 130 | 131 | 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. 132 | 133 | 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: 134 | 135 | Initializes the configuration files at the given locations: 136 | ```bash 137 | anylint --init blank --path Sources/lint.swift --path Tests/lint.swift 138 | ``` 139 | 140 | Runs the lint checks for both configuration files: 141 | ```bash 142 | anylint --path Sources/lint.swift --path Tests/lint.swift 143 | ``` 144 | 145 | There are also several flags you can pass to `anylint`: 146 | 147 | 1. `-s` / `--strict`: Fails on warnings as well. (By default, the command only fails on errors.) 148 | 1. `-x` / `--xcode`: Prints warnings & errors in a format to be reported right within Xcodes left sidebar. 149 | 1. `-l` / `--validate`: Runs only validations for `matchingExamples`, `nonMatchingExamples` and `autoCorrectExamples`. 150 | 1. `-u` / `--unvalidated`: Runs the checks without validating their correctness. Only use for faster subsequent runs after a validated run succeeded. 151 | 1. `-m` / `--measure`: Prints the time it took to execute each check for performance optimizations. 152 | 1. `-v` / `--version`: Prints the current tool version. (Does not run any lint checks.) 153 | 1. `-d` / `--debug`: Logs much more detailed information about what AnyLint is doing for debugging purposes. 154 | 155 | ## Configuration 156 | 157 | AnyLint provides three different kinds of lint checks: 158 | 159 | 1. `checkFileContents`: Matches the contents of a text file to a given regex. 160 | 2. `checkFilePaths`: Matches the file paths of the current directory to a given regex. 161 | 3. `customCheck`: Allows to write custom Swift code to do other kinds of checks. 162 | 163 | Several examples of lint checks can be found in the [`lint.swift` file of this very project](https://github.com/FlineDev/AnyLint/blob/main/lint.swift). 164 | 165 | ### Basic Types 166 | 167 | Independent from the method used, there are a few types specified in the AnyLint package you should know of. 168 | 169 | #### Regex 170 | 171 | Many parameters in the above mentioned lint check methods are of `Regex` type. A `Regex` can be initialized in several ways: 172 | 173 | 1. Using a **String**: 174 | ```swift 175 | let regex = Regex(#"(foo|bar)[0-9]+"#) // => /(foo|bar)[0-9]+/ 176 | let regexWithOptions = Regex(#"(foo|bar)[0-9]+"#, options: [.ignoreCase, .dotMatchesLineSeparators, .anchorsMatchLines]) // => /(foo|bar)[0-9]+/im 177 | ``` 178 | 2. Using a **String Literal**: 179 | ```swift 180 | let regex: Regex = #"(foo|bar)[0-9]+"# // => /(foo|bar)[0-9]+/ 181 | let regexWithOptions: Regex = #"(foo|bar)[0-9]+\im"# // => /(foo|bar)[0-9]+/im 182 | ``` 183 | 3. Using a **Dictionary Literal**: (use for [named capture groups](https://www.regular-expressions.info/named.html)) 184 | ```swift 185 | let regex: Regex = ["key": #"foo|bar"#, "num": "[0-9]+"] // => /(?foo|bar)(?[0-9]+)/ 186 | let regexWithOptions: Regex = ["key": #"foo|bar"#, "num": "[0-9]+", #"\"#: "im"] // => /(?foo|bar)(?[0-9]+)/im 187 | ``` 188 | 189 | Note that we recommend using [raw strings](https://www.hackingwithswift.com/articles/162/how-to-use-raw-strings-in-swift) (`#"foo"#` instead of `"foo"`) for all regexes to get rid of double escaping backslashes (e.g. `\\s` becomes `\s`). This also allows for testing regexes in online regex editors like [Rubular](https://rubular.com/) first and then copy & pasting from them without any additional escaping (except for `{` & `}`, replace with `\{` & `\}`). 190 | 191 |
192 | Regex Options 193 | 194 | Specifying Regex options in literals is done via the `\` separator as shown in the examples above. The available options are: 195 | 196 | 1. `i` for `.ignoreCase`: Any specified characters will both match uppercase and lowercase variants. 197 | 2. `m` for `.dotMatchesLineSeparators`: All appearances of `.` in regexes will also match newlines (which are not matched against by default). 198 | 199 | The `.anchorsMatchLines` option is always activated on literal usage as we strongly recommend it. It ensures that `^` can be used to match the start of a line and `$` for the end of a line. By default they would match the start & end of the _entire string_. If that's actually what you want, you can still use `\A` and `\z` for that. This makes the default literal Regex behavior more in line with sites like [Rubular](https://rubular.com/). 200 | 201 |
202 | 203 | #### CheckInfo 204 | 205 | A `CheckInfo` contains the basic information about a lint check. It consists of: 206 | 207 | 1. `id`: The identifier of your lint check. For example: `EmptyTodo` 208 | 2. `hint`: The hint explaining the cause of the violation or the steps to fix it. 209 | 3. `severity`: The severity of violations. One of `error`, `warning`, `info`. Default: `error` 210 | 211 | While there is an initializer available, we recommend using a String Literal instead like so: 212 | 213 | ```swift 214 | // accepted structure: (@): 215 | let checkInfo: CheckInfo = "ReadmePath: The README file should be named exactly `README.md`." 216 | let checkInfoCustomSeverity: CheckInfo = "ReadmePath@warning: The README file should be named exactly `README.md`." 217 | ``` 218 | 219 | #### AutoCorrection 220 | 221 | An `AutoCorrection` contains an example `before` and `after` string to validate that a given autocorrection rule behaves correctly. 222 | 223 | It can be initialized in two ways, either with the default initializer: 224 | 225 | ```swift 226 | let example: AutoCorrection = AutoCorrection(before: "Lisence", after: "License") 227 | ``` 228 | 229 | Or using a Dictionary literal: 230 | 231 | ```swift 232 | let example: AutoCorrection = ["before": "Lisence", "after": "License"] 233 | ``` 234 | 235 | ### Check File Contents 236 | 237 | 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. 238 | 239 | In its simplest form, the method just requires a `checkInfo` and a `regex`: 240 | 241 | ```swift 242 | // MARK: EmptyTodo 243 | try Lint.checkFileContents( 244 | checkInfo: "EmptyTodo: TODO comments should not be empty.", 245 | regex: #"// TODO: *\n"# 246 | ) 247 | ``` 248 | 249 | But we *strongly recommend* to always provide also: 250 | 251 | 1. `matchingExamples`: Array of strings expected to match the given string for `regex` validation. 252 | 2. `nonMatchingExamples`: Array of strings not matching the given string for `regex` validation. 253 | 3. `includeFilters`: Array of `Regex` objects to include to the file paths to check. 254 | 255 | The first two will be used on each run of AnyLint to check if the provided `regex` actually works as expected. If any of the `matchingExamples` doesn't match or if any of the `nonMatchingExamples` _does_ match, the entire AnyLint command will fail early. This a built-in validation step to help preventing a lot of issues and increasing your confidence on the lint checks. 256 | 257 | The third one is recommended because it increases the performance of the linter. Only files at paths matching at least one of the provided regexes will be checked. If not provided, all files within the current directory will be read recursively for each check, which is inefficient. 258 | 259 | Here's the *recommended minimum example*: 260 | 261 | ```swift 262 | // MARK: - Variables 263 | let swiftSourceFiles: Regex = #"Sources/.*\.swift"# 264 | let swiftTestFiles: Regex = #"Tests/.*\.swift"# 265 | 266 | // MARK: - Checks 267 | // MARK: empty_todo 268 | try Lint.checkFileContents( 269 | checkInfo: "EmptyTodo: TODO comments should not be empty.", 270 | regex: #"// TODO: *\n"#, 271 | matchingExamples: ["// TODO:\n"], 272 | nonMatchingExamples: ["// TODO: not yet implemented\n"], 273 | includeFilters: [swiftSourceFiles, swiftTestFiles] 274 | ) 275 | ``` 276 | 277 | There's 5 more parameters you can optionally set if needed: 278 | 279 | 1. `excludeFilters`: Array of `Regex` objects to exclude from the file paths to check. 280 | 1. `violationLocation`: Specifies the position of the violation marker violations should be reported. Can be the `lower` or `upper` end of a `fullMatch` or `captureGroup(index:)`. 281 | 1. `autoCorrectReplacement`: Replacement string which can reference any capture groups in the `regex`. 282 | 1. `autoCorrectExamples`: Example structs with `before` and `after` for autocorrection validation. 283 | 1. `repeatIfAutoCorrected`: Repeat check if at least one auto-correction was applied in last run. Defaults to `false`. 284 | 285 | The `excludeFilters` can be used alternatively to the `includeFilters` or alongside them. If used alongside, exclusion will take precedence over inclusion. 286 | 287 | If `autoCorrectReplacement` is provided, AnyLint will automatically replace matches of `regex` with the given replacement string. Capture groups are supported, both in numbered style (`([a-z]+)(\d+)` => `$1$2`) and named group style (`(?[a-z])(?\d+)` => `$alpha$num`). When provided, we strongly recommend to also provide `autoCorrectExamples` for validation. Like for `matchingExamples` / `nonMatchingExamples` the entire command will fail early if one of the examples doesn't correct from the `before` string to the expected `after` string. 288 | 289 | > *Caution:* When using the `autoCorrectReplacement` parameter, be sure to double-check that your regex doesn't match too much content. Additionally, we strongly recommend to commit your changes regularly to have some backup. 290 | 291 | Here's a *full example using all parameters* at once: 292 | 293 | ```swift 294 | // MARK: - Variables 295 | let swiftSourceFiles: Regex = #"Sources/.*\.swift"# 296 | let swiftTestFiles: Regex = #"Tests/.*\.swift"# 297 | 298 | // MARK: - Checks 299 | // MARK: empty_method_body 300 | try Lint.checkFileContents( 301 | checkInfo: "EmptyMethodBody: Don't use whitespaces for the body of empty methods.", 302 | regex: [ 303 | "declaration": #"func [^\(\s]+\([^{]*\)"#, 304 | "spacing": #"\s*"#, 305 | "body": #"\{\s+\}"# 306 | ], 307 | violationLocation: .init(range: .fullMatch, bound: .upper), 308 | matchingExamples: [ 309 | "func foo2bar() { }", 310 | "func foo2bar(x: Int, y: Int) { }", 311 | "func foo2bar(\n x: Int,\n y: Int\n) {\n \n}", 312 | ], 313 | nonMatchingExamples: [ 314 | "func foo2bar() {}", 315 | "func foo2bar(x: Int, y: Int) {}" 316 | ], 317 | includeFilters: [swiftSourceFiles], 318 | excludeFilters: [swiftTestFiles], 319 | autoCorrectReplacement: "$declaration {}", 320 | autoCorrectExamples: [ 321 | ["before": "func foo2bar() { }", "after": "func foo2bar() {}"], 322 | ["before": "func foo2bar(x: Int, y: Int) { }", "after": "func foo2bar(x: Int, y: Int) {}"], 323 | ["before": "func foo2bar()\n{\n \n}", "after": "func foo2bar() {}"], 324 | ] 325 | ) 326 | ``` 327 | 328 | Note that when `autoCorrectReplacement` produces a replacement string that exactly matches the matched string of `regex`, then no violation will be reported. This enables us to provide more generic `regex` patterns that also match the correct string without actually reporting a violation for the correct one. For example, using the regex ` if\s*\(([^)]+)\)\s*\{` to check whitespaces around braces after `if` statement would report a violation for all of the following examples: 329 | 330 | ```Java 331 | if(x == 5) { /* some code */ } 332 | if (x == 5){ /* some code */ } 333 | if(x == 5){ /* some code */ } 334 | if (x == 5) { /* some code */ } 335 | ``` 336 | 337 | The problem is that the last example actually is our expected formatting and should not violate. By providing an `autoCorrectReplacement` of ` if ($1) {`, we can fix that as the replacement would be equal to the matched string, so no violation would be reported for the last example and all the others would be auto-corrected – just what we want. 🎉 338 | 339 | (The alternative would be to split the check to two separate ones, one fore checking the prefix and one the suffix whitespacing – not so beautiful as this blows up our `lint.swift` configuration file very quickly.) 340 | 341 | #### Skip file content checks 342 | 343 | While the `includeFilters` and `excludeFilters` arguments in the config file can be used to skip checks on specified files, sometimes it's necessary to make **exceptions** and specify that within the files themselves. For example this can become handy when there's a check which works 99% of the time, but there might be the 1% of cases where the check is reporting **false positives**. 344 | 345 | For such cases, there are **2 ways to skip checks** within the files themselves: 346 | 347 | 1. `AnyLint.skipHere: `: Will skip the specified check(s) on the same line and the next line. 348 | 349 | ```swift 350 | var x: Int = 5 // AnyLint.skipHere: MinVarNameLength 351 | 352 | // or 353 | 354 | // AnyLint.skipHere: MinVarNameLength 355 | var x: Int = 5 356 | ``` 357 | 358 | 2. `AnyLint.skipInFile: `: Will skip `All` or specificed check(s) in the entire file. 359 | 360 | ```swift 361 | // AnyLint.skipInFile: MinVarNameLength 362 | 363 | var x: Int = 5 364 | var y: Int = 5 365 | ``` 366 | or 367 | 368 | ```swift 369 | // AnyLint.skipInFile: All 370 | 371 | var x: Int = 5 372 | var y: Int = 5 373 | ``` 374 | 375 | It is also possible to skip multiple checks at once in a line like so: 376 | 377 | ```swift 378 | // AnyLint.skipHere: MinVarNameLength, LineLength, ColonWhitespaces 379 | ``` 380 | 381 | ### Check File Paths 382 | 383 | The `checkFilePaths` method has all the same parameters like the `checkFileContents` method, so please read the above section to learn more about them. There's only one difference and one additional parameter: 384 | 385 | 1. `autoCorrectReplacement`: Here, this will safely move the file using the path replacement. 386 | 2. `violateIfNoMatchesFound`: Will report a violation if _no_ matches are found if `true`. Default: `false` 387 | 388 | As this method is about file paths and not file contents, the `autoCorrectReplacement` actually also fixes the paths, which corresponds to moving files from the `before` state to the `after` state. Note that moving/renaming files here is done safely, which means that if a file already exists at the resulting path, the command will fail. 389 | 390 | By default, `checkFilePaths` will fail if the given `regex` matches a file. If you want to check for the _existence_ of a file though, you can set `violateIfNoMatchesFound` to `true` instead, then the method will fail if it does _not_ match any file. 391 | 392 | ### Custom Checks 393 | 394 | 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: 395 | 396 | 1. `checkInfo`: Provides some general information on the lint check. 397 | 2. `customClosure`: Your custom logic which produces an array of `Violation` objects. 398 | 399 | 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. 400 | 401 | 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/FlineDev/HandySwift#regex) (the project, the class was taken from) or read the [code documentation comments](https://github.com/FlineDev/AnyLint/blob/main/Sources/Utility/Regex.swift). 402 | 403 | When using the `customCheck`, you might want to also include some Swift packages for [easier file handling](https://github.com/JohnSundell/Files) or [running shell commands](https://github.com/JohnSundell/ShellOut). You can do so by adding them at the top of the file like so: 404 | 405 | ```swift 406 | #!/opt/local/bin/swift-sh 407 | import AnyLint // @FlineDev 408 | import ShellOut // @JohnSundell 409 | 410 | Lint.logSummaryAndExit(arguments: CommandLine.arguments) { 411 | // MARK: - Variables 412 | let projectName: String = "AnyLint" 413 | 414 | // MARK: - Checks 415 | // MARK: LinuxMainUpToDate 416 | try Lint.customCheck(checkInfo: "LinuxMainUpToDate: The tests in Tests/LinuxMain.swift should be up-to-date.") { checkInfo in 417 | var violations: [Violation] = [] 418 | 419 | let linuxMainFilePath = "Tests/LinuxMain.swift" 420 | let linuxMainContentsBeforeRegeneration = try! String(contentsOfFile: linuxMainFilePath) 421 | 422 | let sourceryDirPath = ".sourcery" 423 | try! shellOut(to: "sourcery", arguments: ["--sources", "Tests/\(projectName)Tests", "--templates", "\(sourceryDirPath)/LinuxMain.stencil", "--output", sourceryDirPath]) 424 | 425 | let generatedLinuxMainFilePath = "\(sourceryDirPath)/LinuxMain.generated.swift" 426 | let linuxMainContentsAfterRegeneration = try! String(contentsOfFile: generatedLinuxMainFilePath) 427 | 428 | // move generated file to LinuxMain path to update its contents 429 | try! shellOut(to: "mv", arguments: [generatedLinuxMainFilePath, linuxMainFilePath]) 430 | 431 | if linuxMainContentsBeforeRegeneration != linuxMainContentsAfterRegeneration { 432 | violations.append( 433 | Violation( 434 | checkInfo: checkInfo, 435 | filePath: linuxMainFilePath, 436 | appliedAutoCorrection: AutoCorrection( 437 | before: linuxMainContentsBeforeRegeneration, 438 | after: linuxMainContentsAfterRegeneration 439 | ) 440 | ) 441 | ) 442 | } 443 | 444 | return violations 445 | } 446 | } 447 | 448 | ``` 449 | 450 | ## Xcode Build Script 451 | 452 | If you are using AnyLint for a project in Xcode, you can configure a build script to run it on each build. In order to do this select your target, choose the `Build Phases` tab and click the + button on the top left corner of that pane. Select `New Run Script Phase` and copy the following into the text box below the `Shell: /bin/sh` of your new run script phase: 453 | 454 | ```bash 455 | export PATH="$PATH:/opt/homebrew/bin" 456 | 457 | if which anylint > /dev/null; then 458 | anylint -x 459 | else 460 | echo "warning: AnyLint not installed, see from https://github.com/FlineDev/AnyLint" 461 | fi 462 | ``` 463 | 464 | Next, make sure the AnyLint script runs before the steps `Compiling Sources` by moving it per drag & drop, for example right after `Dependencies`. You probably also want to rename it to somethng like `AnyLint`. 465 | 466 | > **_Note_**: There's a [known bug](https://github.com/mxcl/swift-sh/issues/113) when the build script is used in non-macOS platforms targets. 467 | 468 | ## Regex Cheat Sheet 469 | 470 | Refer to the Regex quick reference on [rubular.com](https://rubular.com/) which all apply for Swift as well: 471 |

472 | 474 |

475 | 476 | In Swift, there are some **differences to regexes in Ruby** (which rubular.com is based on) – take care when copying regexes: 477 | 478 | 1. In Ruby, forward slashes (`/`) must be escaped (`\/`), that's not necessary in Swift. 479 | 2. In Swift, curly braces (`{` & `}`) must be escaped (`\{` & `\}`), that's not necessary in Ruby. 480 | 481 | Here are some **advanced Regex features** you might want to use or learn more about: 482 | 483 | 1. Back references can be used within regexes to match previous capture groups. 484 | 485 | For example, you can make sure that the PR number and link match in `PR: [#100](https://github.com/FlineDev/AnyLint/pull/100)` by using a capture group (`(\d+)`) and a back reference (`\1`) like in: `\[#(\d+)\]\(https://[^)]+/pull/\1\)`. 486 | 487 | [Learn more](https://www.regular-expressions.info/backref.html) 488 | 489 | 2. Negative & positive lookaheads & lookbehinds allow you to specify patterns with some limitations that will be excluded from the matched range. They are specified with `(?=PATTERN)` (positive lookahead), `(?!PATTERN)` (negative lookahead), `(?<=PATTERN)` (positive lookbehind) or `(?