Skip to content

Commit

Permalink
Swift Argument Parser for the CLI
Browse files Browse the repository at this point in the history
Allows for a simpler, more maintainable CLI.
  • Loading branch information
liamrosenfeld authored and cmyr committed Mar 8, 2020
1 parent 20c39ce commit f20f100
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 157 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
language: rust
osx_image: xcode10.2
osx_image: xcode11.3

git:
submodules: true
Expand Down
21 changes: 6 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,23 +120,14 @@ XiEditor includes a CLI for opening files directly from the command line.
### Usage

```text
xi <file> [--wait | -w] [--help | -h]
open XiEditor at specified file path
USAGE: xi [<files> ...] [--wait]
file
the path to the file (relative or absolute)
ARGUMENTS:
<files> Relative or absolute path to the file(s) to open. If none, opens empty editor.
--wait, -w
wait for the editor to close
--help, -h
prints this
xi [--help | -h]
open XiEditor
--help, -h
prints this
OPTIONS:
--wait Wait for the editor to close before finishing process.
-h, --help Show help information.
```

### Git Editor
Expand Down
11 changes: 1 addition & 10 deletions Sources/XiCli/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation
import XiCliCore

let args = Arguments()
let tool = CommandLineTool(args: args)

do {
try tool.run()
} catch {
print("Whoops! An error occurred: \(error)")
exit(1)
}
Xi.main()
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2018 The xi-editor Authors.
// Copyright 2020 The xi-editor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -12,44 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import Cocoa
import AppKit
import ArgumentParser

public final class CommandLineTool {
private let args: Arguments

public init(args: Arguments) {
self.args = args
}

public func run() throws {
if args.help == true {
help()
return
}

guard !args.fileInputs.isEmpty else {
NSWorkspace.shared.launchApplication("XiEditor")
return
}

let filePaths = try args.fileInputs.map {
try resolvePath(from: $0)
}

for filePath in filePaths {
try openFile(at: filePath)
}

if args.wait, !filePaths.isEmpty {
print("waiting for editor to close...")
let group = DispatchGroup()
group.enter()
setObserver(group: group, filePaths: filePaths)
group.wait()
}
}

func resolvePath(from input: String) throws -> String {
struct CliHelper {
static func resolvePath(from input: String) throws -> String {
let fileManager = FileManager.default
var filePath: URL!

Expand All @@ -66,28 +33,28 @@ public final class CommandLineTool {
let pathString = canonicalPath(input)

guard pathIsNotDirectory(pathString) else {
throw CliError.pathIsDirectory
throw ValidationError("The path entered is to a directory")
}

if !fileManager.fileExists(atPath: pathString) {
let createSuccess = fileManager.createFile(atPath: pathString, contents: nil, attributes: nil)

guard createSuccess else {
throw CliError.couldNotCreateFile
throw RuntimeError("Could not create a file")
}
}

return pathString
}

func openFile(at path: String) throws {
static func openFile(at path: String) throws {
let openSuccess = NSWorkspace.shared.openFile(path, withApplication: "XiEditor")
guard openSuccess else {
throw CliError.failedToOpenEditor
throw RuntimeError("Xi editor could not be opened")
}
}

func setObserver(group: DispatchGroup, filePaths: [String]) {
static func setObserver(group: DispatchGroup, filePaths: [String]) {
let notificationQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "Notification queue"
Expand All @@ -110,33 +77,9 @@ public final class CommandLineTool {
}
}
}

func help() {
let message = """
The Xi CLI Help:
xi <file>... [--wait | -w] [--help | -h]
file
the path to the file (relative or absolute)
--wait, -w
wait for the editor to close
--help, -h
prints this
"""
print(message)
}

func canonicalPath(_ path: String) -> String {
static func canonicalPath(_ path: String) -> String {
return URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path
}
}

public extension CommandLineTool {
enum CliError: Swift.Error {
case couldNotCreateFile
case failedToOpenEditor
case pathIsDirectory
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2018 The xi-editor Authors.
// Copyright 2020 The xi-editor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -12,22 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.

public struct Arguments {
var fileInputs: [String] = []
var wait: Bool = false
var help: Bool = false
import Foundation

struct RuntimeError: Error, CustomStringConvertible {
var description: String

public init(arguments: [String] = CommandLine.arguments) {
let actualArgs = Array(arguments.dropFirst())
for arg in actualArgs {
if arg == "--wait" || arg == "-w" {
self.wait = true
} else if arg == "--help" || arg == "-h" {
self.help = true
} else {
self.fileInputs.append(arg)
}
}
init(_ description: String) {
self.description = description
}
}

52 changes: 52 additions & 0 deletions Sources/XiCliCore/Xi.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2020 The xi-editor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArgumentParser
import AppKit

public struct Xi: ParsableCommand {
@Argument(help: "Relative or absolute path to the file(s) to open. If none, opens empty editor.")
var files: [String]

@Flag(help: "Wait for the editor to close before finishing process.")
var wait: Bool

public init() {}

public func run() throws {

guard !files.isEmpty else {
NSWorkspace.shared.launchApplication("XiEditor")
return
}

let filePaths = try files.map {
try CliHelper.resolvePath(from: $0)
}

for filePath in filePaths {
try CliHelper.openFile(at: filePath)
}

if wait, !filePaths.isEmpty {
print("waiting for editor to close...")
let group = DispatchGroup()
group.enter()
CliHelper.setObserver(group: group, filePaths: filePaths)
group.wait()
}
}
}


Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2018 The xi-editor Authors.
// Copyright 2020 The xi-editor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -15,14 +15,9 @@
import XCTest
@testable import XiCliCore

class XiCliCoreTests: XCTestCase {

var commandLineTool: CommandLineTool!

class CliHelperTests: XCTestCase {
override func setUp() {
super.setUp()
let testArguments = Arguments(arguments: ["test.txt", "--wait"])
commandLineTool = CommandLineTool(args: testArguments)
let fileManager = FileManager.default
fileManager.createFile(atPath: "test.txt", contents: "This is a tester file".data(using: .utf8), attributes: nil)
try? fileManager.createSymbolicLink(atPath: "test_link.txt", withDestinationPath: "test.txt")
Expand All @@ -33,23 +28,14 @@ class XiCliCoreTests: XCTestCase {
try? FileManager.default.removeItem(atPath: "test_link.txt")
try? FileManager.default.removeItem(atPath: "test.txt")
}

func testArgParse() {
let arguments = ["xi", "--wait", "test1.txt", "test2.txt"]
let retrievedArgs = Arguments(arguments: arguments)
XCTAssertNotNil(retrievedArgs)
XCTAssert(retrievedArgs.fileInputs == ["test1.txt", "test2.txt"])
XCTAssert(retrievedArgs.wait == true)
XCTAssert(retrievedArgs.help == false)
}


func testResolveAbsolutePath() {
let fromPath = fileInTempDir("testResolvePath")
FileManager
.default
.createFile(atPath: fromPath, contents: "This is a tester file".data(using: .utf8), attributes: nil)
do {
let path = try commandLineTool.resolvePath(from: fromPath)
let path = try CliHelper.resolvePath(from: fromPath)
XCTAssert(path == fromPath)
} catch {
XCTFail("temp file in temp dir not found")
Expand All @@ -58,7 +44,7 @@ class XiCliCoreTests: XCTestCase {

func testResolveRelativePath() {
do {
let path = try commandLineTool.resolvePath(from: "test.txt")
let path = try CliHelper.resolvePath(from: "test.txt")
let expectedPath = fileInCurrentDir("test.txt")
XCTAssert(path == expectedPath, "path does not match expected")
} catch {
Expand All @@ -68,7 +54,7 @@ class XiCliCoreTests: XCTestCase {

func testResolveSymlink() {
do {
let path = try commandLineTool.resolvePath(from: "test_link.txt")
let path = try CliHelper.resolvePath(from: "test_link.txt")
let expectedPath = fileInCurrentDir("test.txt")
XCTAssert(path == expectedPath, "path does not match expected")
} catch {
Expand All @@ -77,15 +63,15 @@ class XiCliCoreTests: XCTestCase {
}

func testFileOpen() {
XCTAssertNoThrow(try commandLineTool.openFile(at: "test.txt"))
XCTAssertNoThrow(try CliHelper.openFile(at: "test.txt"))
}

func testObserver() {
let group = DispatchGroup()
group.enter()
let observedPaths = ["filePath1", "test_link.txt"]
let expectedPaths = [fileInCurrentDir("filePath1"), fileInCurrentDir("test.txt")]
commandLineTool.setObserver(group: group, filePaths: expectedPaths)
CliHelper.setObserver(group: group, filePaths: expectedPaths)
DistributedNotificationCenter.default().post(name: Notification.Name("io.xi-editor.XiEditor.FileClosed"), object: nil, userInfo: ["path": observedPaths.first!])
DistributedNotificationCenter.default().post(name: Notification.Name("io.xi-editor.XiEditor.FileClosed"), object: nil, userInfo: ["path": observedPaths.last!])
let expectation = XCTestExpectation(description: "Notification Recieved")
Expand Down
Loading

0 comments on commit f20f100

Please sign in to comment.