├── .gitignore ├── .typokana_ignore ├── Example └── SampleViewModel.swift ├── Images ├── how_to_set_up.png └── screenshot.png ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── typokana │ ├── Diagnostics.swift │ ├── FileUtil.swift │ ├── IgnoredWordList.swift │ ├── SpellVisitor.swift │ └── main.swift └── Tests ├── LinuxMain.swift └── typokanaTests ├── XCTestManifests.swift └── typokanaTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /.typokana_ignore: -------------------------------------------------------------------------------- 1 | # Add words that the spell checker should ignore. 2 | # (`#` means a line comment.) 3 | deinit 4 | Hashable 5 | Iterable 6 | Codable 7 | autoclosure 8 | 9 | # name 10 | typokana 11 | yuka 12 | ezura 13 | 14 | # library 15 | # rx 16 | # snp 17 | -------------------------------------------------------------------------------- /Example/SampleViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxCocoa 3 | import RxSwift 4 | 5 | protocol SampleViewModeling { 6 | var inputs: SampleViewModelInputs { get } 7 | var outputs: SampleViewModelOutputs { get } 8 | var coordinatorOutputs: SampleViewModelCoordinatorOutputs { get } 9 | } 10 | 11 | protocol SampleViewModelInputs { 12 | var viewWillAppear: PublishRelay { get } 13 | var okButtonDidTap: PublishRelay { get } 14 | } 15 | 16 | protocol SampleViewModelOutputs { 17 | var isOkButtonEnabled: Driver { get } 18 | var showError: Signal { get } 19 | } 20 | 21 | protocol SampleViewModelCoordinatorOutputs { 22 | var show: Signal { get } 23 | } 24 | 25 | enum Test { 26 | case typo 27 | case sample 28 | case hellow 29 | } 30 | 31 | enum TestEnumSyntaxWithDefaultValue { 32 | case success1(String = "test") 33 | case success2(Int? = nil) 34 | case success3(label: String = "") 35 | case warning1(lebel: Bool = false) 36 | case warnining2(label: Bool = true) 37 | } 38 | 39 | // test text sampl 40 | // misspell 41 | final class SampleViewModel: SampleViewModelInputs, SampleViewModelOutputs, SampleViewModelCoordinatorOutputs, SampleViewModeling { 42 | 43 | var inputs: SampleViewModelInputs { return self } 44 | var outputs: SampleViewModelOutputs { return self } 45 | var coordinatorOutputs: SampleViewModelCoordinatorOutputs { return self } 46 | 47 | // MARK: - SampleViewModelInputs 48 | let viewWillAppear: PublishRelay 49 | let okBuuttonDidTap: PublishRelay 50 | 51 | // MARK: - 52 | 53 | // ... 54 | 55 | func showIfNecesary() { 56 | } 57 | 58 | func forTest() { 59 | print("forr_test") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Images/how_to_set_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezura/spell-checker-for-swift/d0c5ca5bcf7099a40cfe35b7bfc27782d3ee23e3/Images/how_to_set_up.png -------------------------------------------------------------------------------- /Images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezura/spell-checker-for-swift/d0c5ca5bcf7099a40cfe35b7bfc27782d3ee23e3/Images/screenshot.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yuka Ezura 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | INSTALL_PATH = /usr/local/bin/typokana 2 | 3 | install: 4 | swift package update 5 | swift build -c release 6 | cp -f .build/release/typokana $(INSTALL_PATH) 7 | 8 | uninstall: 9 | rm -f $(INSTALL_PATH) 10 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "llbuild", 6 | "repositoryURL": "https://github.com/apple/swift-llbuild.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "f73b84bc1525998e5e267f9d830c1411487ac65e", 10 | "version": "0.2.0" 11 | } 12 | }, 13 | { 14 | "package": "SwiftPM", 15 | "repositoryURL": "https://github.com/apple/swift-package-manager.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "9abcc2260438177cecd7cf5185b144d13e74122b", 19 | "version": "0.5.0" 20 | } 21 | }, 22 | { 23 | "package": "SwiftSyntax", 24 | "repositoryURL": "https://github.com/apple/swift-syntax.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "844574d683f53d0737a9c6d706c3ef31ed2955eb", 28 | "version": "0.50300.0" 29 | } 30 | }, 31 | { 32 | "package": "SwiftSyntaxExtensions", 33 | "repositoryURL": "https://github.com/ezura/SwiftSyntaxExtensions.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "504d566c52b78155592ef4ba48234e3d240bddbc", 37 | "version": "0.50300.0" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /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: "typokana", 8 | products: [ 9 | .executable(name: "typokana", targets: ["typokana"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/apple/swift-syntax.git", .exact("0.50300.0")), 13 | .package(url: "https://github.com/ezura/SwiftSyntaxExtensions.git", .exact("0.50300.0")), 14 | .package(url: "https://github.com/apple/swift-package-manager.git", .exact("0.5.0")), 15 | ], 16 | targets: [ 17 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 18 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 19 | .target( 20 | name: "typokana", 21 | dependencies: [ 22 | "SwiftSyntax", 23 | "SwiftSyntaxExtensions", 24 | "SPMUtility", 25 | ]), 26 | .testTarget( 27 | name: "typokanaTests", 28 | dependencies: ["typokana"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # No more "Fix typo" commit...! 2 | 3 | This command line tool can check spelling and show proposed correction. 4 | 5 | 6 | 7 | ## Installation 8 | 9 | ### Binary 10 | 11 | We can download binary from [here](https://github.com/ezura/spell-checker-for-swift/releases). 12 | 13 | ### Makefile 14 | 15 | ```shell 16 | $ git clone git@github.com:ezura/spell-checker-for-swift.git 17 | $ cd spell-checker-for-swift 18 | $ make 19 | ``` 20 | 21 | ### [Mint](https://github.com/yonaskolb/mint) 22 | 23 | ```shell 24 | $ mint install ezura/spell-checker-for-swift@5.3.0 25 | ``` 26 | 27 | ## Usage 28 | 29 | ### With Xcode 30 | 31 | #### Set up 32 | Run `typokana init` in the same directory as xcodeproj file. 33 | 34 | #### Add run script 35 | 36 | 37 | To display warnings on Xcode, add a script into "Run Script Phase". 38 | ``` 39 | if which typokana >/dev/null; then 40 | typokana --diff-only 41 | else 42 | echo "`typokana` is not installed." 43 | fi 44 | ``` 45 | This setting is searching typos only in changed swift files (fetched with `git diff`). 46 | 47 | If you want to select a target directory, add the directory path. 48 | ``` 49 | if which typokana >/dev/null; then 50 | typokana /Sources 51 | else 52 | echo "`typokana` is not installed." 53 | fi 54 | ``` 55 | 56 | ### Command 57 | 58 | #### `typokana init` 59 | Prepare a list of words that the spell checker ignores. 60 | `typokana init` creates ".typokana_ignore" in the current directory. 61 | 62 | #### `typokana` 63 | Search typo in all swift files. 64 | Show warning for typo on Xcode when this command is run at "Run Script". 65 | 66 | #### `typokana -diff` 67 | This command is recommended. 68 | Search typo only in changed swift files (fetched with `git diff`). 69 | 70 | #### `typokana --language` 71 | Change the language to use for spell checking, e.g. "en_US" (defaults to using the system language). 72 | (`typokana --language en_US`) 73 | 74 | #### `typokana --help` 75 | (`typokana --help`) 76 | 77 | ``` 78 | OVERVIEW: Spell check 79 | 80 | USAGE: typokana [options] argument 81 | 82 | OPTIONS: 83 | --diff-only, -diff Check only files listed by `git diff --name-only` 84 | --language, -l The language to use for spell checking, e.g. "en_US" (defaults to using the system language). 85 | --help Display available options 86 | 87 | POSITIONAL ARGUMENTS: 88 | path | init Path to target file | set up typokana 89 | ``` 90 | 91 | ### How to ignore words 92 | 1. Create file named ".typokana_ignore" 93 | 1. Write ignored words with line breaks 94 | 95 | For example, if you don't want to display warnings for "typokana", "json" and "yuka", please write following text in ".typokana_ignore". 96 | ```text:.typokana_ignore 97 | typokana 98 | json 99 | yuka 100 | ``` 101 | -------------------------------------------------------------------------------- /Sources/typokana/Diagnostics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Diagnostics.swift 3 | // typokana 4 | // 5 | // Created by yuka ezura on 2019/04/29. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Diagnostics { 11 | func emit(filePath: String, line: Int, column: Int, message: String) { 12 | print("\(filePath):\(line):\(column): warning: \(message)") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/typokana/FileUtil.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileUtil.swift 3 | // typokana 4 | // 5 | // Created by yuka ezura on 2019/04/29. 6 | // 7 | 8 | import Foundation 9 | import Basic 10 | 11 | func visitFiles(in root: AbsolutePath, onFind: (AbsolutePath) throws -> Void) rethrows { 12 | if root.extension == "swift" { 13 | try onFind(root) 14 | } else { 15 | for content in (try? localFileSystem.getDirectoryContents(root)) ?? [] { 16 | let path = AbsolutePath(content, relativeTo: root) 17 | try visitFiles(in: path, onFind: onFind) 18 | } 19 | } 20 | } 21 | 22 | func getGitRoot() throws -> String { 23 | let process = Process(args: "git", "rev-parse", "--show-toplevel") 24 | try process.launch() 25 | let result = try process.waitUntilExit() 26 | return try result.utf8Output().trimmingCharacters(in: .newlines) 27 | } 28 | 29 | func extractModifiedFiles() throws -> [String] { 30 | let processOfGitDiff = Process(args: "git", "diff", "--diff-filter=d", "--name-only", "HEAD") 31 | try processOfGitDiff.launch() 32 | let result = try processOfGitDiff.waitUntilExit() 33 | return try result.utf8Output().split(separator: "\n").map { String($0) } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/typokana/IgnoredWordList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoredWordList.swift 3 | // typokana 4 | // 5 | // Created by yuka ezura on 2020/09/19. 6 | // 7 | 8 | import Foundation 9 | import Basic 10 | 11 | enum IgnoredWordList { 12 | 13 | static private let fileName = ".typokana_ignore" 14 | static private let filePath = "./\(fileName)" 15 | 16 | static func read() -> [String] { 17 | do { 18 | let list = try String(contentsOfFile: filePath, encoding: .utf8) 19 | return list.split(separator: "\n") 20 | .filter { !$0.hasPrefix("#") } // Remove comment lines 21 | .map { String($0) } 22 | } catch { 23 | print("\u{001B}[42mwarning: ignore list could not read.\u{001B}[0m") 24 | return [] 25 | } 26 | } 27 | 28 | static func generateTemplateFileIfNeeds() { 29 | let fileManager = FileManager() 30 | if fileManager.fileExists(atPath: filePath) { return 31 | print("\(fileName) file exists. skip the step to generate \(fileName) file.") 32 | } 33 | let isFileCreated = fileManager.createFile(atPath: filePath, 34 | contents: template.data(using: .utf8), 35 | attributes: nil) 36 | if isFileCreated { 37 | print("success: '\(fileName)' created") 38 | } else { 39 | print("\u{001B}[42mfail: '\(fileName)' can't be created\u{001B}[0m") 40 | } 41 | } 42 | } 43 | 44 | extension IgnoredWordList { 45 | private static let template = 46 | """ 47 | # Add words that the spell checker should ignore. 48 | # (`#` means a line comment.) 49 | deinit 50 | Hashable 51 | Iterable 52 | Codable 53 | autoclosure 54 | json 55 | 56 | # rx 57 | # snp 58 | """ 59 | } 60 | -------------------------------------------------------------------------------- /Sources/typokana/SpellVisitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpellVisitor.swift 3 | // typokana 4 | // 5 | // Created by yuka ezura on 2019/04/29. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | import SwiftSyntax 11 | import SwiftSyntaxExtensions 12 | 13 | class SpellVisitor: SyntaxVisitor { 14 | let filePath: String 15 | let spellChecker: NSSpellChecker 16 | let sourceLocationConverter: SourceLocationConverter 17 | 18 | init(filePath: String, spellChecker: NSSpellChecker, sourceLocationConverter: SourceLocationConverter) { 19 | self.filePath = filePath 20 | self.spellChecker = spellChecker 21 | self.sourceLocationConverter = sourceLocationConverter 22 | } 23 | 24 | override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind { 25 | for comment in token.leadingTrivia.compactMap({ $0.comment }) { 26 | let misspellRange = spellChecker.checkSpelling(of: comment, startingAt: 0) 27 | if misspellRange.location < comment.count { 28 | printMisspelled(forWordRange: misspellRange, 29 | in: comment, 30 | position: sourceLocationConverter.location(for: token.positionAfterSkippingLeadingTrivia)) 31 | } 32 | } 33 | 34 | switch token.tokenKind { 35 | case .stringLiteral(let text), 36 | .unknown(let text), 37 | .identifier(let text), 38 | .dollarIdentifier(let text), 39 | .stringSegment(let text): 40 | let formedText = text.reduce([]) { (r, c) -> [String] in 41 | var _r = r 42 | if c.isUppercase { 43 | _r.append(String(c)) 44 | } else if c == "_" || c == "." { 45 | _r.append("") 46 | } else { 47 | var lastText = (_r.popLast() ?? "") 48 | lastText.append(c) 49 | _r.append(lastText) 50 | } 51 | return _r 52 | }.joined(separator: " ") 53 | let misspelledRange = spellChecker.checkSpelling(of: formedText, startingAt: 0) 54 | if misspelledRange.location < formedText.count { 55 | let position = sourceLocationConverter.location(for: token.position) 56 | printMisspelled(forWordRange: misspelledRange, 57 | in: formedText, 58 | position: position) 59 | // TODO: Resume check spelling from continuation of text 60 | } 61 | default: 62 | break 63 | } 64 | 65 | return .visitChildren 66 | } 67 | 68 | private func printMisspelled(forWordRange misspelledRange: NSRange, in string: String, position: SourceLocation) { 69 | let suggestedWord = spellChecker.correction(forWordRange: misspelledRange, 70 | in: string, 71 | language: spellChecker.language(), 72 | inSpellDocumentWithTag: 0) 73 | var message: String { 74 | let targetWord = (string as NSString).substring(with: misspelledRange) 75 | if let suggestedWord = suggestedWord { 76 | return #""\#(targetWord)": did you mean "\#(suggestedWord)"? (CheckSpelling)"# 77 | } else { 78 | return #""\#(targetWord)" (CheckSpelling)"# 79 | } 80 | } 81 | 82 | guard let line = position.line, let column = position.column else { 83 | assertionFailure("Can't get position: \(position)") 84 | Diagnostics().emit(filePath: filePath, 85 | line: 0, 86 | column: 0, 87 | message: message) 88 | return 89 | } 90 | 91 | Diagnostics().emit(filePath: filePath, 92 | line: line, 93 | column: column, 94 | message: message) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/typokana/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpellVisitor.swift 3 | // typokana 4 | // 5 | // Created by yuka ezura on 2019/04/29. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | import SPMUtility 11 | import Basic 12 | import SwiftSyntax 13 | 14 | let parser = ArgumentParser(usage: "[options] argument", overview: "Spell check") 15 | let arg = parser.add(positional: "path | init", 16 | kind: String.self, 17 | optional: true, 18 | usage: "Path to target file | set up typokana", 19 | completion: nil) 20 | let optionForDiffOnly = parser.add(option: "--diff-only", 21 | shortName: "-diff", 22 | kind: Bool.self, 23 | usage: "Check only files listed by `git diff --name-only`") 24 | 25 | let optionForLanguage = parser.add(option: "--language", 26 | shortName: "-l", 27 | kind: String.self, 28 | usage: "The language to use for spell checking, e.g. \"en_US\" (defaults to using the system language).") 29 | 30 | do { 31 | let result = try parser.parse(Array(CommandLine.arguments.dropFirst())) 32 | let argument = result.get(arg) 33 | if argument == "init" { 34 | setUpConfigurations() 35 | exit(0) 36 | } 37 | 38 | guard let cwd = localFileSystem.currentWorkingDirectory else { exit(1) } 39 | let path = AbsolutePath(result.get(arg) ?? "./", relativeTo: cwd) 40 | let shouldCheckDiffOnly = result.get(optionForDiffOnly) ?? false 41 | let targetFiles: [AbsolutePath] = try { 42 | if shouldCheckDiffOnly { 43 | let gitRoot = AbsolutePath(try getGitRoot()) 44 | return try extractModifiedFiles().compactMap { path in 45 | let formattedPath = AbsolutePath(path, relativeTo: gitRoot) 46 | guard formattedPath.extension == "swift" else { return nil } 47 | return formattedPath 48 | } 49 | } else { 50 | var targetFileBuffer: [AbsolutePath] = [] 51 | visitFiles(in: path) { (path) in 52 | guard path.extension == "swift" else { return } 53 | targetFileBuffer.append(path) 54 | } 55 | return targetFileBuffer 56 | } 57 | }() 58 | 59 | let spellChecker = NSSpellChecker.shared 60 | 61 | let ignoredWords = IgnoredWordList.read() 62 | spellChecker.setIgnoredWords(ignoredWords, inSpellDocumentWithTag: 0) 63 | 64 | if let language = result.get(optionForLanguage) { 65 | guard spellChecker.setLanguage(language) else { 66 | print(#""\#(language)" is not a valid language."#) 67 | print(#"Valid languages include: \#(spellChecker.availableLanguages.joined(separator: ", "))"#) 68 | exit(1) 69 | } 70 | } 71 | 72 | try targetFiles.forEach { 73 | let syntaxTree = try SyntaxParser.parse($0.asURL) 74 | let sourceLocationConverter = SourceLocationConverter(file: $0.pathString, tree: syntaxTree) 75 | let spellVisitor = SpellVisitor(filePath: $0.pathString, spellChecker: spellChecker, sourceLocationConverter: sourceLocationConverter) 76 | spellVisitor.walk(syntaxTree) 77 | } 78 | } catch { 79 | print(error.localizedDescription) 80 | } 81 | 82 | 83 | func setUpConfigurations() { 84 | IgnoredWordList.generateTemplateFileIfNeeds() 85 | print("✅ complete") 86 | } 87 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import typodesuTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += typodesuTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/typokanaTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(typokanaTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/typokanaTests/typokanaTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class typokanaTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | 10 | // Some of the APIs that we use below are available in macOS 10.13 and above. 11 | guard #available(macOS 10.13, *) else { 12 | return 13 | } 14 | 15 | let fooBinary = productsDirectory.appendingPathComponent("typokana") 16 | 17 | let process = Process() 18 | process.executableURL = fooBinary 19 | 20 | let pipe = Pipe() 21 | process.standardOutput = pipe 22 | 23 | try process.run() 24 | process.waitUntilExit() 25 | 26 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 27 | let output = String(data: data, encoding: .utf8) 28 | 29 | XCTAssertEqual(output, "Hello, world!\n") 30 | } 31 | 32 | /// Returns path to the built products directory. 33 | var productsDirectory: URL { 34 | #if os(macOS) 35 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 36 | return bundle.bundleURL.deletingLastPathComponent() 37 | } 38 | fatalError("couldn't find the products directory") 39 | #else 40 | return Bundle.main.bundleURL 41 | #endif 42 | } 43 | 44 | static var allTests = [ 45 | ("testExample", testExample), 46 | ] 47 | } 48 | --------------------------------------------------------------------------------