├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── HumanString.podspec ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── HumanString │ ├── HumanString.swift │ ├── Regex.swift │ └── StringIndexExtensions.swift └── Tests ├── HumanStringTests ├── HumanStringTests.swift ├── RegexTests.swift ├── StringIndexExtensionsTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | name: Build 7 | runs-on: macOS-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Build 11 | run: swift build -Xswiftc "-sdk" -Xswiftc "`xcrun --sdk iphonesimulator --show-sdk-path`" -Xswiftc "-target" -Xswiftc "x86_64-apple-ios12.1-simulator" 12 | working-directory: ./ 13 | test: 14 | name: Test 15 | runs-on: macOS-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Build 19 | run: swift test --enable-code-coverage 20 | working-directory: ./ 21 | - name: Test coverage 22 | uses: maxep/spm-lcov-action@0.3.0 23 | with: 24 | output-file: ./coverage/lcov.info 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: PublishDocumentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy_docs: 9 | runs-on: macos-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Publish Jazzy Docs 13 | uses: steven0351/publish-jazzy-docs@v1 14 | with: 15 | personal_access_token: ${{ secrets.ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | .vscode 6 | .swiftpm 7 | .idea 8 | buildConfigurations -------------------------------------------------------------------------------- /HumanString.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'HumanString' 3 | s.version = '0.1.1' 4 | s.summary = 'Makes substrings in Swift for human again.' 5 | s.description = <<-DESC 6 | Sometimes you just want an easier way to work on substrings, so I made this. 7 | DESC 8 | 9 | s.homepage = 'https://github.com/zonble/HumanString' 10 | s.license = {:type => 'MIT', :file => 'LICENSE'} 11 | s.author = {'zonble' => 'zonble@gmail.com'} 12 | s.source = {:git => 'https://github.com/zonble/HumanString.git', :tag => s.version.to_s} 13 | s.swift_versions = ['4.2', '5.0', '5.1', '5.2', '5.3', '5.5'] 14 | s.source_files = 'Sources/HumanString/**/*' 15 | s.ios.deployment_target = '8.0' 16 | s.osx.deployment_target = '10.10' 17 | s.watchos.deployment_target = "2.0" 18 | s.tvos.deployment_target = "9.0" 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Weizhong Yang 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "HumanString", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "HumanString", 12 | targets: ["HumanString"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "HumanString", 23 | dependencies: []), 24 | .testTarget( 25 | name: "HumanStringTests", 26 | dependencies: ["HumanString"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HumanString 2 | 3 | [![Actions Status](https://github.com/zonble/HumanString/workflows/Build/badge.svg)](https://github.com/zonble/HumanString/actions) 4 | 5 | HumanString is a package that let you use integers but not String.Index to 6 | specify ranges in strings. 7 | 8 | ``` swift 9 | let str = "今天天氣好清爽" 10 | XCTAssertTrue(str[0] == "今") 11 | XCTAssertTrue(str[4] == "好") 12 | XCTAssertTrue(str[-1] == "爽") 13 | XCTAssertTrue(str[0..<2] == "今天") 14 | XCTAssertTrue(str[0...2] == "今天天") 15 | XCTAssertTrue(str[-3 ..< -1] == "好清") 16 | XCTAssertTrue(str[-3 ... -1] == "好清爽") 17 | XCTAssertTrue(str[-3 ... 0] == nil) 18 | XCTAssertTrue(str[-3 ..< 0] == "好清爽") 19 | XCTAssertTrue(str[-3 ..< 1] == nil) 20 | XCTAssertTrue(str[-3 ... 1] == nil) 21 | XCTAssertTrue(str[0...] == "今天天氣好清爽") 22 | XCTAssertTrue(str[1...] == "天天氣好清爽") 23 | XCTAssertTrue(str[(-2)...] == "清爽") 24 | XCTAssertTrue(str[(-1)...] == "爽") 25 | XCTAssertTrue(str[..<3] == "今天天") 26 | XCTAssertTrue(str[...3] == "今天天氣") 27 | XCTAssertTrue(str[..<(-1)] == "今天天氣好清") 28 | XCTAssertTrue(str[...(-1)] == "今天天氣好清爽") 29 | ``` 30 | 31 | ## Installation 32 | 33 | You can install the package using Swift package manager. Add the following lines to your `Packages.swift` file: 34 | 35 | ``` 36 | dependencies: [ 37 | .package(url: "https://github.com/zonble/HumanString.git"), 38 | ], 39 | ``` 40 | 41 | You can also install the library using CocoaPods, just add `pod "HumanString"` 42 | to you Podfile. 43 | 44 | ## Notes 45 | 46 | Swift adopts String.Index since Swift 4. It tends to reminder you that the width 47 | of a string is not fixed (See[Strings in Swift 4](https://oleb.net/blog/2017/11/swift-4-strings/)). 48 | However, it is somehow painful. For example, if you want to extract a prefix 49 | from "Hello World", it could be: 50 | 51 | ``` swift 52 | let str = "Hello World" 53 | let subString = s[...str.index(str.startIndex, offsetBy: 5)] 54 | ``` 55 | 56 | But wait, somehow you are still using integers, right? The code above could be 57 | written as: 58 | 59 | ``` swift 60 | let subString = s[str.index(str.startIndex, offsetBy: 0)...str.index(str.startIndex, offsetBy: 5)] 61 | ``` 62 | 63 | I know the Swift team does not like what I am doing here, but somehow I still 64 | want to make my life easier. 65 | -------------------------------------------------------------------------------- /Sources/HumanString/HumanString.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The errors that could happen in HumanString library. 4 | public enum HumanStringError: Error, LocalizedError { 5 | /// The range is invalid. 6 | case invalidRange 7 | 8 | public var errorDescription: String? { 9 | switch self { 10 | case .invalidRange: 11 | return "The range is invalid." 12 | } 13 | } 14 | } 15 | 16 | extension String { 17 | func convert(_ i: Int) -> String.Index { 18 | return i < 0 ? self.index(self.endIndex, offsetBy: i) : self.index(self.startIndex, offsetBy: i) 19 | } 20 | 21 | func convert(_ r: PartialRangeFrom) -> PartialRangeFrom { 22 | return convert(r.lowerBound)... 23 | } 24 | 25 | func convert(_ r: PartialRangeUpTo) -> PartialRangeUpTo { 26 | return ..) -> PartialRangeThrough { 30 | return ...convert(r.upperBound) 31 | } 32 | 33 | func convert(_ r: ClosedRange) -> ClosedRange? { 34 | if r.lowerBound < 0 && r.upperBound < 0 { 35 | let lowerBound = self.index(self.endIndex, offsetBy: r.lowerBound) 36 | let upperBound = self.index(self.endIndex, offsetBy: r.upperBound) 37 | return lowerBound...upperBound 38 | } else if r.lowerBound >= 0 && r.upperBound >= 0 { 39 | let lowerBound = self.index(self.startIndex, offsetBy: r.lowerBound) 40 | let upperBound = self.index(self.startIndex, offsetBy: r.upperBound) 41 | return lowerBound...upperBound 42 | } 43 | return nil 44 | } 45 | 46 | func convert(_ r: Range) -> Range? { 47 | if r.lowerBound <= 0 && r.upperBound <= 0 { 48 | let lowerBound = self.index(self.endIndex, offsetBy: r.lowerBound) 49 | let upperBound = self.index(self.endIndex, offsetBy: r.upperBound) 50 | return lowerBound..= 0 && r.upperBound >= 0 { 52 | let lowerBound = self.index(self.startIndex, offsetBy: r.lowerBound) 53 | let upperBound = self.index(self.startIndex, offsetBy: r.upperBound) 54 | return lowerBound.. Character { 65 | return self[convert(i)] 66 | } 67 | 68 | /// Returns a character at the given range. 69 | /// - Parameter r: The range. 70 | subscript(r: PartialRangeFrom) -> Substring? { 71 | return self[convert(r)] 72 | } 73 | 74 | /// Returns a character at the given range. 75 | /// - Parameter r: The range. 76 | subscript(r: PartialRangeUpTo) -> Substring? { 77 | return self[convert(r)] 78 | } 79 | 80 | /// Returns a character at the given range. 81 | /// - Parameter r: The range. 82 | subscript(r: PartialRangeThrough) -> Substring? { 83 | return self[convert(r)] 84 | } 85 | 86 | /// Returns a character at the given range. 87 | /// - Parameter r: The range. 88 | subscript(r: ClosedRange) -> Substring? { 89 | guard let range = convert(r) else { 90 | return nil 91 | } 92 | return self[range] 93 | } 94 | 95 | /// Returns a character at the given range. 96 | /// - Parameter r: The range. 97 | subscript(r: Range) -> Substring? { 98 | guard let range = convert(r) else { 99 | return nil 100 | } 101 | return self[range] 102 | } 103 | } 104 | 105 | public extension String { 106 | 107 | func substring(from index: Int) -> String? { 108 | if let substring = self[index...] { 109 | return String(substring) 110 | } 111 | return nil 112 | } 113 | 114 | func substring(to index: Int) -> String? { 115 | if let substring = self[..) -> String? { 122 | if let substring = self[aRange.startIndex.. String.Index { 134 | return convert(i) 135 | } 136 | } 137 | 138 | public extension String { 139 | 140 | /// Inserts a character at a given position. 141 | /// - Parameters: 142 | /// - newElement: The new character. 143 | /// - position: The given index. 144 | mutating func insert(_ newElement: __owned Character, at position: Int) { 145 | insert(newElement, at: convert(position)) 146 | } 147 | 148 | /// Removes a character at a given position. 149 | /// - Parameter position: The given position 150 | /// - Returns: The removes character. 151 | mutating func remove(at position: Int) -> Character { 152 | return remove(at: convert(position)) 153 | } 154 | 155 | /// Removes characters in a given range. 156 | /// - Parameter bounds: The given range. 157 | mutating func removeSubrange(_ bounds: Range) { 158 | guard let range = convert(bounds) else { 159 | return 160 | } 161 | removeSubrange(range) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/HumanString/Regex.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A helper that makes using Regular Expression easier. 4 | public struct Regex { 5 | 6 | /// Find all matches in a string with a given pattern. 7 | /// 8 | /// - Parameters: 9 | /// - pattern: The pattern. 10 | /// - string: The string. 11 | /// - options: The options to build the `NSRegularExpression` object. 12 | /// - matchingOptions: The options for matching. 13 | /// - Throws: The errors that could happen during creating the `NSRegularExpression` object. 14 | /// - Returns: The matches. 15 | public static func findAll(pattern: String, 16 | string: String, 17 | options: NSRegularExpression.Options = [], 18 | matchingOptions: NSRegularExpression.MatchingOptions = [] 19 | ) throws -> [Match] { 20 | let regex = try NSRegularExpression(pattern: pattern, options: options) 21 | let matches = regex.matches(in: string, options: matchingOptions, range: NSMakeRange(0, string.count)) 22 | return matches.map { 23 | return Match($0, string) 24 | } 25 | } 26 | 27 | /// Find the first match in a string with a given pattern. 28 | /// 29 | /// - Parameters: 30 | /// - pattern: The pattern. 31 | /// - string: The string. 32 | /// - options: The options to build the `NSRegularExpression` object. 33 | /// - matchingOptions: The options for matching. 34 | /// - Throws: The errors that could happen during creating the `NSRegularExpression` object. 35 | /// - Returns: The first match. 36 | public func findFirst(pattern: String, 37 | string: String, 38 | options: NSRegularExpression.Options = [], 39 | matchingOptions: NSRegularExpression.MatchingOptions = [] 40 | ) throws -> Match? { 41 | let regex = try NSRegularExpression(pattern: pattern, options: options) 42 | guard let result = regex.firstMatch(in: string, options: matchingOptions, range: NSMakeRange(0, string.count)) else { 43 | return nil 44 | } 45 | return Match(result, string) 46 | } 47 | 48 | /// Makes a new string by replacing the matches with a template. 49 | /// 50 | /// - Parameters: 51 | /// - pattern: The pattern. 52 | /// - string: The string. 53 | /// - template: The template. 54 | /// - options: The options to build the `NSRegularExpression` object. 55 | /// - matchingOptions: The options for matching. 56 | /// - Throws: The errors that could happen during creating the `NSRegularExpression` object. 57 | /// - Returns: The replaced string. 58 | public func replace(pattern: String, 59 | string: String, 60 | template: String, 61 | options: NSRegularExpression.Options = [], 62 | matchingOptions: NSRegularExpression.MatchingOptions = [] 63 | ) throws -> String { 64 | let regex = try NSRegularExpression(pattern: pattern, options: options) 65 | return regex.stringByReplacingMatches(in: string, options: matchingOptions, range: NSMakeRange(0, string.count), withTemplate: template) 66 | } 67 | } 68 | 69 | /// Represents the matches. 70 | public struct Match { 71 | /// The string. 72 | public private (set) var string: String 73 | /// The `NSTextCheckingResult` object. 74 | public private (set) var checkResult: NSTextCheckingResult 75 | 76 | fileprivate init(_ checkResult: NSTextCheckingResult, _ string: String) { 77 | self.checkResult = checkResult 78 | self.string = string 79 | } 80 | 81 | /// The position of the matching range. 82 | public var position: Int { 83 | return checkResult.range.location 84 | } 85 | 86 | /// The end position of the matching range, 87 | public var endPosition: Int { 88 | return checkResult.range.location + checkResult.range.length 89 | } 90 | 91 | /// The match groups. 92 | /// - Returns: An array of string. 93 | public func groups() -> [String] { 94 | let ranges = self.checkResult.numberOfRanges 95 | return (0.. String? { 105 | if index >= self.checkResult.numberOfRanges { 106 | return nil 107 | } 108 | let range = self.checkResult.range(at: index) 109 | return self.string.substring(with: range.location..<(range.location + range.length)) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/HumanString/StringIndexExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String.Index { 4 | /// Makes a new String.Index object by setting an offset on the 5 | /// index on the left hand side. 6 | static func +(left: String.Index, right: (String, Int)) -> String.Index { 7 | return right.0.index(left, offsetBy: right.1) 8 | } 9 | 10 | /// Makes a new String.Index object by setting an offset on the 11 | /// index on the left hand side. 12 | static func -(left: String.Index, right: (String, Int)) -> String.Index { 13 | return right.0.index(left, offsetBy: -1 * right.1) 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /Tests/HumanStringTests/HumanStringTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HumanString 3 | 4 | final class HumanStringTests: XCTestCase { 5 | func testSubstring1() { 6 | let str = "今天天氣好清爽" 7 | XCTAssertTrue(str[0] == "今") 8 | XCTAssertTrue(str[4] == "好") 9 | XCTAssertTrue(str[-1] == "爽") 10 | XCTAssertTrue(str[0..<2] == "今天") 11 | XCTAssertTrue(str[0...2] == "今天天") 12 | XCTAssertTrue(str[-3 ..< -1] == "好清") 13 | XCTAssertTrue(str[-3 ... -1] == "好清爽") 14 | XCTAssertTrue(str[-3 ... 0] == nil) 15 | XCTAssertTrue(str[-3 ..< 0] == "好清爽") 16 | XCTAssertTrue(str[-3 ..< 1] == nil) 17 | XCTAssertTrue(str[-3 ... 1] == nil) 18 | XCTAssertTrue(str[0...] == "今天天氣好清爽") 19 | XCTAssertTrue(str[1...] == "天天氣好清爽") 20 | XCTAssertTrue(str[(-2)...] == "清爽") 21 | XCTAssertTrue(str[(-1)...] == "爽") 22 | XCTAssertTrue(str[..<3] == "今天天") 23 | XCTAssertTrue(str[...3] == "今天天氣") 24 | XCTAssertTrue(str[..<(-1)] == "今天天氣好清") 25 | XCTAssertTrue(str[...(-1)] == "今天天氣好清爽") 26 | } 27 | 28 | func testSubstring2() { 29 | let str = "abcde" 30 | XCTAssertTrue(str[0] == "a") 31 | XCTAssertTrue(str[4] == "e") 32 | XCTAssertTrue(str[-1] == "e") 33 | XCTAssertTrue(str[0..<2] == "ab") 34 | XCTAssertTrue(str[0...2] == "abc") 35 | XCTAssertTrue(str[-3 ..< -1] == "cd") 36 | XCTAssertTrue(str[-3 ... -1] == "cde") 37 | XCTAssertTrue(str[-3 ... 0] == nil) 38 | XCTAssertTrue(str[-3 ..< 0] == "cde") 39 | XCTAssertTrue(str[-3 ..< 1] == nil) 40 | XCTAssertTrue(str[-3 ... 1] == nil) 41 | } 42 | 43 | func testAt() { 44 | let str = "abcde" 45 | let index1 = str.index(at: 1) 46 | XCTAssertTrue(str[index1] == "b") 47 | let index2 = str.index(at: -1) 48 | XCTAssertTrue(str[index2] == "e") 49 | } 50 | 51 | func testOp() { 52 | let str = "abcde" 53 | let i = str.startIndex + (str, 4) 54 | XCTAssertTrue(str[i] == "e") 55 | } 56 | 57 | func testInsert() { 58 | var str = "abcde" 59 | str.insert("a", at: 0) 60 | XCTAssert(str == "aabcde") 61 | } 62 | 63 | func testRemove() { 64 | var str = "abcde" 65 | let a = str.remove(at: 0) 66 | XCTAssert(str == "bcde") 67 | XCTAssert(a == "a") 68 | } 69 | 70 | static var allTests = [ 71 | ("testSubstring1", testSubstring1), 72 | ("testSubstring2", testSubstring2), 73 | ("testAt", testAt), 74 | ("testOp", testOp), 75 | ("testInsert", testInsert), 76 | ("testRemove", testRemove), 77 | ] 78 | } 79 | 80 | -------------------------------------------------------------------------------- /Tests/HumanStringTests/RegexTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HumanString 3 | 4 | final class RegexTests: XCTestCase { 5 | 6 | func testRegex() { 7 | let str = "Hello, playground" 8 | let pattern = "l(.*?)p" 9 | do { 10 | let matches = try Regex.findAll(pattern: pattern, string: str) 11 | XCTAssert(matches.count == 1) 12 | let match = matches[0] 13 | XCTAssert(match.group(at: 0) == "llo, p", "\(String(describing: match.group(at: 0)))") 14 | XCTAssert(match.group(at: 1) == "lo, ", "\(String(describing: match.group(at: 1)))") 15 | XCTAssert(match.groups() == ["llo, p", "lo, "]) 16 | } catch { 17 | XCTFail() 18 | } 19 | } 20 | 21 | static var allTests = [ 22 | ("testRegex", testRegex), 23 | ] 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Tests/HumanStringTests/StringIndexExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HumanString 3 | 4 | final class StringIndexExtensionsTests: XCTestCase { 5 | func testAdd() { 6 | let s = "abcde" 7 | let index = s.startIndex + (s, 1) 8 | XCTAssert(s[index] == "b") 9 | } 10 | 11 | func testSubstract() { 12 | let s = "abcde" 13 | let index = s.endIndex - (s, 2) 14 | XCTAssert(s[index] == "d", "\(s[index])") 15 | } 16 | 17 | func testSubstract2() { 18 | let s = "abcde" 19 | let index = (s.startIndex + (s, 2)) - (s, 1) 20 | XCTAssert(s[index] == "b", "\(s[index])") 21 | } 22 | 23 | static var allTests = [ 24 | ("testAdd", testAdd), 25 | ("testSubstract", testSubstract), 26 | ("testSubstract2", testSubstract2), 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Tests/HumanStringTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(HumanStringTests.allTests), 7 | testCase(RegexTests.allTests), 8 | testCase(StringIndexExtensionsTests.allTests), 9 | ] 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import HumanStringTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += HumanStringTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------