From 70896991d10ebf8392c11d593e84a8847395a2e4 Mon Sep 17 00:00:00 2001 From: Geoffrey Foster Date: Sun, 30 Jun 2019 14:17:06 -0400 Subject: [PATCH] adds implementation of pathspec with gitignore style matching --- .../xcshareddata/xcschemes/Pathspec.xcscheme | 78 ++++++ Package.swift | 21 ++ README.md | 8 +- Sources/Pathspec/GitIgnoreSpec.swift | 154 +++++++++++ Sources/Pathspec/Pathspec.swift | 41 +++ Sources/Pathspec/RegexSpec.swift | 16 ++ Sources/Pathspec/Spec.swift | 21 ++ Tests/PathspecTests/PathspecTests.swift | 259 ++++++++++++++++++ 8 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Pathspec.xcscheme create mode 100644 Package.swift create mode 100644 Sources/Pathspec/GitIgnoreSpec.swift create mode 100644 Sources/Pathspec/Pathspec.swift create mode 100644 Sources/Pathspec/RegexSpec.swift create mode 100644 Sources/Pathspec/Spec.swift create mode 100644 Tests/PathspecTests/PathspecTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Pathspec.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Pathspec.xcscheme new file mode 100644 index 0000000..4198d15 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Pathspec.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..cf992af --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:5.0 +import PackageDescription + +let package = Package( + name: "Pathspec", + products: [ + .library( + name: "Pathspec", + targets: ["Pathspec"]), + ], + dependencies: [ + ], + targets: [ + .target( + name: "Pathspec", + dependencies: []), + .testTarget( + name: "PathspecTests", + dependencies: ["Pathspec"]), + ] +) diff --git a/README.md b/README.md index 7d0c8fe..83c546c 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# Pathspec \ No newline at end of file +# Pathspec + +A Swift library for pattern matching file paths using git's matching format (used for [.gitignore](http://git-scm.com/docs/gitignore) files) + +Ported from: +- [Python pathspec](https://github.com/cpburnz/python-path-specification) +- [Ruby pathspec](https://github.com/highb/pathspec-ruby) diff --git a/Sources/Pathspec/GitIgnoreSpec.swift b/Sources/Pathspec/GitIgnoreSpec.swift new file mode 100644 index 0000000..5154810 --- /dev/null +++ b/Sources/Pathspec/GitIgnoreSpec.swift @@ -0,0 +1,154 @@ +// +// GitIgnoreSpec.swift +// Pathspec +// +// Created by Geoffrey Foster on 2019-06-29. +// + +import Foundation + +class GitIgnoreSpec: Spec { + private(set) var inclusive: Bool = true + 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 } + + var pattern = pattern + if pattern.hasPrefix("!") { + inclusive = false + pattern.removeFirst() + } + + if pattern.starts(with: "\\") { + pattern.removeFirst(2) + } + + var patternSegments = pattern.components(separatedBy: "/") + if patternSegments[0].isEmpty { + patternSegments.removeFirst() + } else if patternSegments.count == 1 || (patternSegments.count == 2 && patternSegments[1].isEmpty) { + if patternSegments[0] != "**" { + patternSegments.insert("**", at: 0) + } + } + if patternSegments.count > 1 && patternSegments[patternSegments.index(before: patternSegments.endIndex)].isEmpty { + patternSegments[patternSegments.index(before: patternSegments.endIndex)] = "**" + } + + let pathSeparator = "/" + var regexString = "^" + var needSlash = false + let lastIndex = patternSegments.index(before: patternSegments.endIndex) + for index in patternSegments.startIndex.. Bool { + return regex.firstMatch(in: file, options: [], range: NSRange(file.startIndex.. String { + var regex = "" + var escape = false + var i = glob.startIndex + while i < glob.endIndex { + let char = glob[i] + i = glob.index(after: i) + + if escape { + escape = false + regex += NSRegularExpression.escapedPattern(for: "\(char)") + } else if char == "\\" { + escape = true + } else if char == "*" { + regex += "[^/]*" + } else if char == "?" { + regex += "[^/]" + } else if char == "[" { + var j = i + if j < glob.endIndex && glob[j] == "!" { + j = glob.index(after: j) + } + if j < glob.endIndex && glob[j] == "]" { + j = glob.index(after: j) + } + while j < glob.endIndex && glob[j] != "]" { + j = glob.index(after: j) + } + if j < glob.endIndex { + var expr = "[" + + if glob[i] == "!" { + expr += "^" + i = glob.index(after: i) + } else if glob[i] == "^" { + expr += #"\^"# + i = glob.index(after: i) + } + + if glob[i] == "]" && i != j { + expr += #"\]"# + i = glob.index(after: i) + } + + expr += glob[i...j].replacingOccurrences(of: "\\", with: "\\\\") + regex += expr + + j = glob.index(after: j) + i = j + } else { + regex += #"\["# + } + } else { + regex += NSRegularExpression.escapedPattern(for: "\(char)") + } + } + + return regex + } +} diff --git a/Sources/Pathspec/Pathspec.swift b/Sources/Pathspec/Pathspec.swift new file mode 100644 index 0000000..dfb687a --- /dev/null +++ b/Sources/Pathspec/Pathspec.swift @@ -0,0 +1,41 @@ +// +// Pathspec.swift +// Pathspec +// +// Created by Geoffrey Foster on 2019-06-29. +// + +public final class Pathspec { + public enum Kind { + case git + case regex + } + private var specs: [Spec] = [] + + public init(kind: Kind, patterns: String...) { + for pattern in patterns { + add(pattern: pattern, kind: kind) + } + } + + public func match(path: String) -> Bool { + return matchingSpecs(path: path).allSatisfy { $0.inclusive } + } + + func add(pattern: String, kind: Kind) { + let spec: Spec? + switch kind { + case .git: + spec = GitIgnoreSpec(pattern: pattern) + case .regex: + spec = RegexSpec() + } + if let spec = spec { + specs.append(spec) + } + } + + private func matchingSpecs(path: String) -> [Spec] { + return specs.filter { $0.match(file: path) } + } +} diff --git a/Sources/Pathspec/RegexSpec.swift b/Sources/Pathspec/RegexSpec.swift new file mode 100644 index 0000000..157d93c --- /dev/null +++ b/Sources/Pathspec/RegexSpec.swift @@ -0,0 +1,16 @@ +// +// RegexSpec.swift +// Pathspec +// +// Created by Geoffrey Foster on 2019-06-29. +// + +import Foundation + +class RegexSpec: Spec { + var inclusive: Bool = false + + func match(file: String) -> Bool { + return false + } +} diff --git a/Sources/Pathspec/Spec.swift b/Sources/Pathspec/Spec.swift new file mode 100644 index 0000000..73e5ac4 --- /dev/null +++ b/Sources/Pathspec/Spec.swift @@ -0,0 +1,21 @@ +// +// Spec.swift +// Pathspec +// +// Created by Geoffrey Foster on 2019-06-29. +// + +import Foundation + +public protocol Spec { + var inclusive: Bool { get } + func match(file: String) -> Bool +} + +extension Spec { + public func match(files: [String]) -> [String] { + return files.filter { (file) -> Bool in + match(file: file) + } + } +} diff --git a/Tests/PathspecTests/PathspecTests.swift b/Tests/PathspecTests/PathspecTests.swift new file mode 100644 index 0000000..d494146 --- /dev/null +++ b/Tests/PathspecTests/PathspecTests.swift @@ -0,0 +1,259 @@ +// +// File.swift +// +// +// Created by Geoffrey Foster on 2019-06-29. +// + +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", + ] + ) + } + + // MARK: - +}