diff --git a/Sources/Pathspec/GitIgnoreSpec.swift b/Sources/Pathspec/GitIgnoreSpec.swift index 38bd512..97937a3 100644 --- a/Sources/Pathspec/GitIgnoreSpec.swift +++ b/Sources/Pathspec/GitIgnoreSpec.swift @@ -8,14 +8,25 @@ import Foundation struct GitIgnoreSpec: Spec { + enum Error: Swift.Error { + case emptyPattern + case commented + case invalid + case emptyRoot + } + private(set) var inclusive: Bool = true + + let pattern: String let regex: NSRegularExpression - - init?(pattern: String) { - guard !pattern.isEmpty else { return nil } - guard !pattern.hasPrefix("#") else { return nil } - guard !pattern.contains("***") else { return nil } - guard pattern != "/" else { return nil } + + init(pattern: String) throws { + self.pattern = pattern + + guard !pattern.isEmpty else { throw Error.emptyPattern } + guard !pattern.hasPrefix("#") else { throw Error.commented } + guard !pattern.contains("***") else { throw Error.invalid } + guard pattern != "/" else { throw Error.emptyRoot } var pattern = pattern if pattern.hasPrefix("!") { @@ -81,11 +92,7 @@ struct GitIgnoreSpec: Spec { regexString += "$" - do { - regex = try NSRegularExpression(pattern: regexString, options: []) - } catch { - return nil - } + regex = try NSRegularExpression(pattern: regexString, options: []) } func match(file: String) -> Bool { @@ -152,3 +159,18 @@ struct GitIgnoreSpec: Spec { return regex } } + +extension GitIgnoreSpec: CustomStringConvertible { + var description: String { + let pattern = self.pattern.debugDescription + return "<\(type(of: self)) pattern: \(pattern)>" + } +} + +extension GitIgnoreSpec: CustomDebugStringConvertible { + var debugDescription: String { + let pattern = self.pattern.debugDescription + let regexPattern = self.regex.pattern.debugDescription + return "<\(type(of: self)) pattern: \(pattern) regex: \(regexPattern)>" + } +} diff --git a/Sources/Pathspec/Pathspec.swift b/Sources/Pathspec/Pathspec.swift index f0b6f9b..620c192 100644 --- a/Sources/Pathspec/Pathspec.swift +++ b/Sources/Pathspec/Pathspec.swift @@ -8,11 +8,15 @@ public final class Pathspec { private let specs: [Spec] - public init(patterns: String...) { - specs = patterns.compactMap { - GitIgnoreSpec(pattern: $0) - } + public convenience init(patterns: [String]) throws { + self.init(specs: try patterns.map { + try GitIgnoreSpec(pattern: $0) + }) } + + public init(specs: [Spec]) { + self.specs = specs + } public func match(path: String) -> Bool { let matchingSpecs = self.matchingSpecs(path: path) @@ -24,3 +28,31 @@ public final class Pathspec { return specs.filter { $0.match(file: path) } } } + +extension Pathspec: ExpressibleByArrayLiteral { + public typealias ArrayLiteralElement = String + + public convenience init(arrayLiteral: String...) { + self.init(specs: arrayLiteral.compactMap { + try? GitIgnoreSpec(pattern: $0) + }) + } +} + +extension Pathspec: CustomStringConvertible { + public var description: String { + let specsDescription = specs.map { spec in + " " + String(describing: spec) + }.joined(separator: ",\n") + return "<\(type(of: self)) specs: [\n\(specsDescription)\n]>" + } +} + +extension Pathspec: CustomDebugStringConvertible { + public var debugDescription: String { + let specsDescription = specs.map { spec in + " " + String(reflecting: spec) + }.joined(separator: ",\n") + return "<\(type(of: self)) specs: [\n\(specsDescription)\n]>" + } +} diff --git a/Tests/PathspecTests/GitIgnoreSpecTests.swift b/Tests/PathspecTests/GitIgnoreSpecTests.swift new file mode 100644 index 0000000..197e7ca --- /dev/null +++ b/Tests/PathspecTests/GitIgnoreSpecTests.swift @@ -0,0 +1,259 @@ +// +// PathspecTests.swift +// Pathspec +// +// Created by Geoffrey Foster on 2019-06-29. +// + +import XCTest +@testable import Pathspec + +final class GitIgnoreSpecTests: XCTestCase { + func testDescription() throws { + let spec = try XCTUnwrap(GitIgnoreSpec(pattern: "foobar")) + + XCTAssertEqual(spec.description, "") + } + + func testDebugDescription() throws { + let spec = try XCTUnwrap(GitIgnoreSpec(pattern: "foobar")) + + XCTAssertEqual(spec.debugDescription, "") + } + + func testAbsoluteRoot() throws { + XCTAssertThrowsError(try GitIgnoreSpec(pattern: "/")) + } + + func testComment() throws { + XCTAssertThrowsError(try GitIgnoreSpec(pattern: "# Cork soakers.")) + } + + func testIgnore() throws { + let spec = try XCTUnwrap(GitIgnoreSpec(pattern: "!temp")) + XCTAssertFalse(spec.inclusive) + XCTAssertEqual(spec.regex.pattern, "^(?:.+/)?temp$") + let result = spec.match(file: "temp/foo") + XCTAssertEqual(result, false) + } + + // MARK: - Inclusive tests + + @inline(__always) + private func _testRunner(pattern: String, regex: String, files: [String], expectedResults: [String], file: StaticString = #file, line: UInt = #line) throws { + let spec = try XCTUnwrap(GitIgnoreSpec(pattern: pattern), file: file, line: line) + XCTAssertTrue(spec.inclusive, file: file, line: line) + XCTAssertEqual(spec.regex.pattern, regex, file: file, line: line) + let results = spec.match(files: files) + XCTAssertEqual(results, expectedResults, file: file, line: line) + } + + func testAbsolute() throws { + try _testRunner( + pattern: "/an/absolute/file/path", + regex: "^an/absolute/file/path(?:/.*)?$", + files: [ + "an/absolute/file/path", + "an/absolute/file/path/foo", + "foo/an/absolute/file/path", + ], + expectedResults: [ + "an/absolute/file/path", + "an/absolute/file/path/foo", + ] + ) + } + + func testAbsoluteSingleItem() throws { + try _testRunner( + pattern: "/an/", + regex: "^an/.*$", + files: [ + "an/absolute/file/path", + "an/absolute/file/path/foo", + "foo/an/absolute/file/path", + ], + expectedResults: [ + "an/absolute/file/path", + "an/absolute/file/path/foo", + ] + ) + } + + func testRelative() throws { + try _testRunner( + pattern: "spam", + regex: "^(?:.+/)?spam(?:/.*)?$", + files: [ + "spam", + "spam/", + "foo/spam", + "spam/foo", + "foo/spam/bar", + ], + expectedResults: [ + "spam", + "spam/", + "foo/spam", + "spam/foo", + "foo/spam/bar", + ] + ) + } + + func testRelativeNested() throws { + try _testRunner( + pattern: "foo/spam", + regex: "^foo/spam(?:/.*)?$", + files: [ + "foo/spam", + "foo/spam/bar", + "bar/foo/spam", + ], + expectedResults: [ + "foo/spam", + "foo/spam/bar", + ] + ) + } + + func testChildDoubleAsterisk() throws { + try _testRunner( + pattern: "spam/**", + regex: "^spam/.*$", + files: [ + "spam/bar", + "foo/spam/bar" + ], + expectedResults: [ + "spam/bar" + ] + ) + } + + func testInnerDoubleAsterisk() throws { + try _testRunner( + pattern: "left/**/right", + regex: "^left(?:/.+)?/right(?:/.*)?$", + files: [ + "left/bar/right", + "left/foo/bar/right", + "left/bar/right/foo", + "foo/left/bar/right", + ], + expectedResults: [ + "left/bar/right", + "left/foo/bar/right", + "left/bar/right/foo", + ] + ) + } + + func testOnlyDoubleAsterisk() throws { + try _testRunner( + pattern: "**", + regex: "^.+$", + files: [], + expectedResults: [] + ) + } + + func testParentDoubleAsterisk() throws { + try _testRunner( + pattern: "**/spam", + regex: "^(?:.+/)?spam(?:/.*)?$", + files: [ + "foo/spam", + "foo/spam/bar", + ], + expectedResults: [ + "foo/spam", + "foo/spam/bar", + ] + ) + } + + func testInfixWildcard() throws { + try _testRunner( + pattern: "foo-*-bar", + regex: "^(?:.+/)?foo-[^/]*-bar(?:/.*)?$", + files: [ + "foo--bar", + "foo-hello-bar", + "a/foo-hello-bar", + "foo-hello-bar/b", + "a/foo-hello-bar/b", + ], + expectedResults: [ + "foo--bar", + "foo-hello-bar", + "a/foo-hello-bar", + "foo-hello-bar/b", + "a/foo-hello-bar/b", + ] + ) + } + + func testPostfixWildcard() throws { + try _testRunner( + pattern: "~temp-*", + regex: "^(?:.+/)?~temp-[^/]*(?:/.*)?$", + files: [ + "~temp-", + "~temp-foo", + "~temp-foo/bar", + "foo/~temp-bar", + "foo/~temp-bar/baz", + ], + expectedResults: [ + "~temp-", + "~temp-foo", + "~temp-foo/bar", + "foo/~temp-bar", + "foo/~temp-bar/baz", + ] + ) + } + + func testPrefixWildcard() throws { + try _testRunner( + pattern: "*.swift", + regex: "^(?:.+/)?[^/]*\\.swift(?:/.*)?$", + files: [ + "bar.swift", + "bar.swift/", + "foo/bar.swift", + "foo/bar.swift/baz", + ], + expectedResults: [ + "bar.swift", + "bar.swift/", + "foo/bar.swift", + "foo/bar.swift/baz", + ] + ) + } + + func testDirectory() throws { + try _testRunner( + pattern: "dir/", + regex: "^(?:.+/)?dir/.*$", + files: [ + "dir/", + "foo/dir/", + "foo/dir/bar", + "dir", + ], + expectedResults: [ + "dir/", + "foo/dir/", + "foo/dir/bar", + ] + ) + } + + func testFailingInitializers() throws { + XCTAssertThrowsError(try GitIgnoreSpec(pattern: "")) + XCTAssertThrowsError(try GitIgnoreSpec(pattern: "***")) + } +} diff --git a/Tests/PathspecTests/PathspecTests.swift b/Tests/PathspecTests/PathspecTests.swift index 48deb9c..33ce4fd 100644 --- a/Tests/PathspecTests/PathspecTests.swift +++ b/Tests/PathspecTests/PathspecTests.swift @@ -1,262 +1,39 @@ // -// PathspecTests.swift -// Pathspec +// File.swift +// // -// Created by Geoffrey Foster on 2019-06-29. +// Created by Vincent Esche on 4/23/20. // import XCTest @testable import Pathspec -#if swift(<5.1) -private struct UnwrappingFailure: LocalizedError { - var errorDescription: String? { - return "XCTUnwrap failed: throwing an unknown exception" - } -} -public func XCTUnwrap(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) throws -> T { - guard let unwrapped = try expression() else { - XCTFail(message(), file: file, line: line) - throw UnwrappingFailure() - } - return unwrapped -} -#endif - final class PathspecTests: XCTestCase { - func testAbsoluteRoot() throws { - XCTAssertNil(GitIgnoreSpec(pattern: "/")) - } - - func testComment() throws { - XCTAssertNil(GitIgnoreSpec(pattern: "# Cork soakers.")) - } - - func testIgnore() throws { - let spec = try XCTUnwrap(GitIgnoreSpec(pattern: "!temp")) - XCTAssertFalse(spec.inclusive) - XCTAssertEqual(spec.regex.pattern, "^(?:.+/)?temp$") - let result = spec.match(file: "temp/foo") - XCTAssertEqual(result, false) - } - - // MARK: - Inclusive tests - - @inline(__always) - private func _testRunner(pattern: String, regex: String, files: [String], expectedResults: [String], file: StaticString = #file, line: UInt = #line) throws { - let spec = try XCTUnwrap(GitIgnoreSpec(pattern: pattern), file: file, line: line) - XCTAssertTrue(spec.inclusive, file: file, line: line) - XCTAssertEqual(spec.regex.pattern, regex, file: file, line: line) - let results = spec.match(files: files) - XCTAssertEqual(results, expectedResults, file: file, line: line) - } - - func testAbsolute() throws { - try _testRunner( - pattern: "/an/absolute/file/path", - regex: "^an/absolute/file/path(?:/.*)?$", - files: [ - "an/absolute/file/path", - "an/absolute/file/path/foo", - "foo/an/absolute/file/path", - ], - expectedResults: [ - "an/absolute/file/path", - "an/absolute/file/path/foo", - ] - ) - } - - func testAbsoluteSingleItem() throws { - try _testRunner( - pattern: "/an/", - regex: "^an/.*$", - files: [ - "an/absolute/file/path", - "an/absolute/file/path/foo", - "foo/an/absolute/file/path", - ], - expectedResults: [ - "an/absolute/file/path", - "an/absolute/file/path/foo", - ] - ) - } - - func testRelative() throws { - try _testRunner( - pattern: "spam", - regex: "^(?:.+/)?spam(?:/.*)?$", - files: [ - "spam", - "spam/", - "foo/spam", - "spam/foo", - "foo/spam/bar", - ], - expectedResults: [ - "spam", - "spam/", - "foo/spam", - "spam/foo", - "foo/spam/bar", - ] - ) - } - - func testRelativeNested() throws { - try _testRunner( - pattern: "foo/spam", - regex: "^foo/spam(?:/.*)?$", - files: [ - "foo/spam", - "foo/spam/bar", - "bar/foo/spam", - ], - expectedResults: [ - "foo/spam", - "foo/spam/bar", - ] - ) - } - - func testChildDoubleAsterisk() throws { - try _testRunner( - pattern: "spam/**", - regex: "^spam/.*$", - files: [ - "spam/bar", - "foo/spam/bar" - ], - expectedResults: [ - "spam/bar" - ] - ) - } - - func testInnerDoubleAsterisk() throws { - try _testRunner( - pattern: "left/**/right", - regex: "^left(?:/.+)?/right(?:/.*)?$", - files: [ - "left/bar/right", - "left/foo/bar/right", - "left/bar/right/foo", - "foo/left/bar/right", - ], - expectedResults: [ - "left/bar/right", - "left/foo/bar/right", - "left/bar/right/foo", - ] - ) - } - - func testOnlyDoubleAsterisk() throws { - try _testRunner( - pattern: "**", - regex: "^.+$", - files: [], - expectedResults: [] - ) - } - - func testParentDoubleAsterisk() throws { - try _testRunner( - pattern: "**/spam", - regex: "^(?:.+/)?spam(?:/.*)?$", - files: [ - "foo/spam", - "foo/spam/bar", - ], - expectedResults: [ - "foo/spam", - "foo/spam/bar", - ] - ) - } - - func testInfixWildcard() throws { - try _testRunner( - pattern: "foo-*-bar", - regex: "^(?:.+/)?foo-[^/]*-bar(?:/.*)?$", - files: [ - "foo--bar", - "foo-hello-bar", - "a/foo-hello-bar", - "foo-hello-bar/b", - "a/foo-hello-bar/b", - ], - expectedResults: [ - "foo--bar", - "foo-hello-bar", - "a/foo-hello-bar", - "foo-hello-bar/b", - "a/foo-hello-bar/b", - ] - ) - } - - func testPostfixWildcard() throws { - try _testRunner( - pattern: "~temp-*", - regex: "^(?:.+/)?~temp-[^/]*(?:/.*)?$", - files: [ - "~temp-", - "~temp-foo", - "~temp-foo/bar", - "foo/~temp-bar", - "foo/~temp-bar/baz", - ], - expectedResults: [ - "~temp-", - "~temp-foo", - "~temp-foo/bar", - "foo/~temp-bar", - "foo/~temp-bar/baz", - ] - ) - } - - func testPrefixWildcard() throws { - try _testRunner( - pattern: "*.swift", - regex: "^(?:.+/)?[^/]*\\.swift(?:/.*)?$", - files: [ - "bar.swift", - "bar.swift/", - "foo/bar.swift", - "foo/bar.swift/baz", - ], - expectedResults: [ - "bar.swift", - "bar.swift/", - "foo/bar.swift", - "foo/bar.swift/baz", - ] - ) - } - - func testDirectory() throws { - try _testRunner( - pattern: "dir/", - regex: "^(?:.+/)?dir/.*$", - files: [ - "dir/", - "foo/dir/", - "foo/dir/bar", - "dir", - ], - expectedResults: [ - "dir/", - "foo/dir/", - "foo/dir/bar", - ] - ) - } - - func testFailingInitializers() { - XCTAssertNil(GitIgnoreSpec(pattern: "")) - XCTAssertNil(GitIgnoreSpec(pattern: "***")) - } + func testDescription() throws { + let spec: Pathspec = try XCTUnwrap(["foo", "foo/bar"]) + + XCTAssertEqual( + spec.description, + """ + , + + ]> + """ + ) + } + + func testDebugDescription() throws { + let spec: Pathspec = try XCTUnwrap(["foo", "foo/bar"]) + + XCTAssertEqual( + spec.debugDescription, + """ + , + + ]> + """ + ) + } } diff --git a/Tests/PathspecTests/XCTUnwrap.swift b/Tests/PathspecTests/XCTUnwrap.swift new file mode 100644 index 0000000..a55ecfa --- /dev/null +++ b/Tests/PathspecTests/XCTUnwrap.swift @@ -0,0 +1,24 @@ +// +// File.swift +// +// +// Created by Vincent Esche on 4/23/20. +// + +import XCTest +@testable import Pathspec + +#if swift(<5.1) +private struct UnwrappingFailure: LocalizedError { + var errorDescription: String? { + return "XCTUnwrap failed: throwing an unknown exception" + } +} +public func XCTUnwrap(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) throws -> T { + guard let unwrapped = try expression() else { + XCTFail(message(), file: file, line: line) + throw UnwrappingFailure() + } + return unwrapped +} +#endif