CSVCommand

Also small refactorings
This commit is contained in:
Alexis Bridoux 2021-04-23 16:15:03 +02:00
parent 6561f04200
commit 3e727aacfa
17 changed files with 281 additions and 184 deletions

View File

@ -39,13 +39,13 @@ extension ExplorerXML {
}
private static func fromArrayOfDictionaries(csv: CSV) throws -> ExplorerXML {
let rootTree = Tree.root()
let rootTree = Tree.root(name: "element")
let headers = try csv.header
.map { try (key: $0, path: Path(string: $0)) } // transform keys to paths
.sorted { $0.path.comparedByKeyAndIndexes(with: $1.path) } // sort by path
.map { ($0.key, rootTree.insert(path: $0.path)) } // insert paths in the tree
let explorer = ExplorerXML(name: Element.defaultName)
let explorer = ExplorerXML(name: Element.root)
try csv.namedRows.forEach { row in
let child = try from(row: row, with: headers, rootTree: rootTree)

View File

@ -8,6 +8,7 @@ import AEXML
extension AEXMLElement {
static let defaultName = "element"
static let root = "root"
func isEqual(to other: AEXMLElement) -> Bool {

View File

@ -45,8 +45,8 @@ final class PathTree<Value: Equatable> {
PathTree(value: .node(children: children), element: element)
}
static func root() -> PathTree {
.node(children: [], element: .key("root"))
static func root(name: String = "root") -> PathTree {
.node(children: [], element: .key(name))
}
}

View File

@ -18,7 +18,7 @@ struct AddCommand: SADCommand {
// MARK: - Properties
@Option(name: DataFormat.name)
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: Scout.DataFormat
@Argument(help: PathAndValue.help)

View File

@ -0,0 +1,96 @@
//
// Scout
// Copyright (c) 2020-present Alexis Bridoux
// MIT license, see LICENSE file for details
import Foundation
import Scout
import ArgumentParser
import ScoutCLTCore
struct CSVCommand: ParsableCommand {
// MARK: - Constants
static let configuration = CommandConfiguration(
commandName: "csv",
abstract: "Convert a CSV input in one of the supported format")
// MARK: - Properties
@Option(name: [.short, .long], help: "The separator used in the CSV input")
var separator: String
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: DataFormat
@Flag(help: "Indicates whether the CSV input has headers and thus should be treated like an array of dictionaries")
var headers: Headers
@Option(name: .inputFilePath, help: .inputFilePath, completion: .file())
var inputFilePath: String?
@Option(name: .outputFilePath, help: .outputFilePath, completion: .file())
var outputFilePath: String?
@Flag(help: .colorise)
var color = ColorFlag.color
// MARK: - Functions
func run() throws {
let inputData = try readDataOrInputStream(from: inputFilePath)
let inputString = try String(data: inputData, encoding: .utf8).unwrapOrThrow(.dataToString)
guard let character = separator.first, separator.count == 1 else {
throw RuntimeError.custom("Argument 'separator' should be a unique character")
}
switch dataFormat {
case .json:
let json = try Json.fromCSV(string: inputString, separator: character, hasHeaders: headers == .headers)
try handleOutput(with: json)
case .plist:
let plist = try Plist.fromCSV(string: inputString, separator: character, hasHeaders: headers == .headers)
try handleOutput(with: plist)
case .yaml:
let yaml = try Yaml.fromCSV(string: inputString, separator: character, hasHeaders: headers == .headers)
try handleOutput(with: yaml)
case .xml:
let xml = try Xml.fromCSV(string: inputString, separator: character, hasHeaders: headers == .headers)
try handleOutput(with: xml)
}
}
func handleOutput<P: SerializablePathExplorer>(with explorer: P) throws {
if let filePath = outputFilePath {
let data = try explorer.exportData()
FileManager.default.createFile(atPath: filePath, contents: data, attributes: nil)
} else {
var output = try explorer.exportString()
let colorInjector = try self.colorInjector(for: dataFormat)
output = colorise ? colorInjector.inject(in: output) : output
print(output)
}
}
}
extension CSVCommand {
enum Headers: EnumerableFlag {
case headers
case noHeaders
}
}
extension CSVCommand {
/// Colorize the output
var colorise: Bool {
if color == .forceColor { return true }
return color.colorise && !FileHandle.standardOutput.isPiped
}
}

View File

@ -18,7 +18,7 @@ struct DeleteCommand: SADCommand {
// MARK: - Properties
@Option(name: DataFormat.name)
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: Scout.DataFormat
@Argument(help: "Paths to indicate the keys to be deleted")

View File

@ -9,5 +9,4 @@ import ArgumentParser
extension DataFormat: ExpressibleByArgument {
public var defaultValueDescription: String { "The data format to read the input" }
static var name: NameSpecification = [.customShort("f", allowingJoined: true), .customLong("format")]
}

View File

@ -0,0 +1,85 @@
//
// Scout
// Copyright (c) 2020-present Alexis Bridoux
// MIT license, see LICENSE file for details
import Foundation
import ArgumentParser
import Scout
import Lux
extension ParsableCommand {
/// Try to read data from the optional `filePath`. Otherwise, return the data from the standard input stream
func readDataOrInputStream(from filePath: String?) throws -> Data {
if let filePath = filePath {
return try Data(contentsOf: URL(fileURLWithPath: filePath.replacingTilde))
}
// The function `readDataToEndOfFile()` was deprecated since macOS 10.15.4
// but now (macOS 11.2.2) it seems to be deprecated for never (100_000)).
return FileHandle.standardInput.readDataToEndOfFile()
// return try input.data(using: .utf8)
// .unwrapOrThrow(error: .invalidData("Unable to get data from standard input"))
// if #available(OSX 10.15.4, *) {
// do {
// return try FileHandle
// .standardInput
// .readToEnd()
// .unwrapOrThrow(error: .invalidData("Unable to get data from standard input"))
// }
// return standardInputData
// } catch {
// throw RuntimeError.invalidData("Error while reading data from standard input. \(error.localizedDescription)")
// }
// } else {
// return FileHandle.standardInput.readDataToEndOfFile()
// }
}
}
extension ParsableCommand {
func colorInjector(for format: Scout.DataFormat) throws -> TextInjector {
switch format {
case .json:
let jsonInjector = JSONInjector(type: .terminal)
if let colors = try getColorFile()?.json {
jsonInjector.delegate = JSONInjectorColorDelegate(colors: colors)
}
return jsonInjector
case .plist:
let plistInjector = PlistInjector(type: .terminal)
if let colors = try getColorFile()?.plist {
plistInjector.delegate = PlistInjectorColorDelegate(colors: colors)
}
return plistInjector
case .yaml:
let yamlInjector = YAMLInjector(type: .terminal)
if let colors = try getColorFile()?.yaml {
yamlInjector.delegate = YAMLInjectorColorDelegate(colors: colors)
}
return yamlInjector
case .xml:
let xmlInjector = XMLEnhancedInjector(type: .terminal)
if let colors = try getColorFile()?.xml {
xmlInjector.delegate = XMLInjectorColorDelegate(colors: colors)
}
return xmlInjector
}
}
/// Retrieve the color file to colorise the output if one is found
func getColorFile() throws -> ColorFile? {
let colorFileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".scout/Colors.plist")
guard let data = try? Data(contentsOf: colorFileURL) else { return nil }
return try PropertyListDecoder().decode(ColorFile.self, from: data)
}
}

View File

@ -7,6 +7,7 @@ import ArgumentParser
extension NameSpecification {
static let dataFormat: NameSpecification = [.customShort("f", allowingJoined: true), .customLong("format")]
static let inputFilePath: NameSpecification = [.short, .customLong("input")]
static let outputFilePath: NameSpecification = [.short, .customLong("output")]
static let modifyFilePath: NameSpecification = [.short, .customLong("modify")]
@ -17,6 +18,7 @@ extension NameSpecification {
extension ArgumentHelp {
static let dataFormat = ArgumentHelp("The data format of the input")
static let inputFilePath = ArgumentHelp("A file path from which to read the data")
static let outputFilePath = ArgumentHelp("Write the modified data into the file at the given path")
static let modifyFilePath = ArgumentHelp("Read and write the data into the same file at the given path")

View File

@ -0,0 +1,78 @@
//
// Scout
// Copyright (c) 2020-present Alexis Bridoux
// MIT license, see LICENSE file for details
import Foundation
import Scout
import ArgumentParser
/// Try to get a PathExplorer from the input
protocol PathExplorerInputCommand: ParsableCommand {
/// A file path from which to read the data
var inputFilePath: String? { get }
/// A file path from which to read and write the data
var modifyFilePath: String? { get }
var dataFormat: Scout.DataFormat { get }
/// Called with the correct `PathExplorer` when `inferPathExplorer(from:in:)` completes
func inferred<P: SerializablePathExplorer>(pathExplorer: P) throws
}
extension PathExplorerInputCommand {
// MARK: - Properties
var modifyFilePath: String? { nil }
// MARK: - Functions
func run() throws {
var filePath: String?
switch (inputFilePath?.replacingTilde, modifyFilePath?.replacingTilde) {
case (.some(let path), nil): filePath = path
case (nil, .some(let path)): filePath = path
case (nil, nil): break
case (.some, .some): throw RuntimeError.invalidArgumentsCombination(description: "Combining (-i|--input) with (-m|--modify) is not allowed")
}
let data = try readDataOrInputStream(from: filePath)
try makePathExplorer(for: dataFormat, from: data)
}
private func makeExplorer<P: SerializablePathExplorer>(_ type: P.Type, from data: Data) throws -> P {
do {
return try P(data: data)
} catch {
throw RuntimeError.unknownFormat("The input cannot be read as \(dataFormat).\n\(error.localizedDescription)")
}
}
func makePathExplorer(for dataFormat: Scout.DataFormat, from data: Data) throws {
switch dataFormat {
case .json:
let json = try makeExplorer(Json.self, from: data)
try inferred(pathExplorer: json)
case .plist:
let plist = try makeExplorer(Plist.self, from: data)
try inferred(pathExplorer: plist)
case .yaml:
let yaml = try makeExplorer(Yaml.self, from: data)
try inferred(pathExplorer: yaml)
case .xml:
let xml = try makeExplorer(Xml.self, from: data)
try inferred(pathExplorer: xml)
}
}
/// Try to get the regex from the pattern, throwing a `RuntimeError` when failing
func regexFrom(pattern: String) throws -> NSRegularExpression {
guard let regex = try? NSRegularExpression(pattern: pattern) else {
throw RuntimeError.invalidRegex(pattern)
}
return regex
}
}

View File

@ -1,168 +0,0 @@
//
// Scout
// Copyright (c) 2020-present Alexis Bridoux
// MIT license, see LICENSE file for details
import Foundation
import Scout
import ArgumentParser
import Lux
protocol ScoutCommand: ParsableCommand {
/// A file path from which to read the data
var inputFilePath: String? { get }
/// A file path from which to read and write the data
var modifyFilePath: String? { get }
var dataFormat: Scout.DataFormat { get }
/// Called with the correct `PathExplorer` when `inferPathExplorer(from:in:)` completes
func inferred<P: SerializablePathExplorer>(pathExplorer: P) throws
}
extension ScoutCommand {
// MARK: - Properties
var modifyFilePath: String? { nil }
// MARK: - Functions
func run() throws {
var filePath: String?
switch (inputFilePath?.replacingTilde, modifyFilePath?.replacingTilde) {
case (.some(let path), nil): filePath = path
case (nil, .some(let path)): filePath = path
case (nil, nil): break
case (.some, .some): throw RuntimeError.invalidArgumentsCombination(description: "Combining (-i|--input) with (-m|--modify) is not allowed")
}
let data = try readDataOrInputStream(from: filePath)
try makePathExplorer(for: dataFormat, from: data)
}
/// Try to read data from the optional `filePath`. Otherwise, return the data from the standard input stream
func readDataOrInputStream(from filePath: String?) throws -> Data {
if let filePath = filePath {
return try Data(contentsOf: URL(fileURLWithPath: filePath.replacingTilde))
}
// The function `readDataToEndOfFile()` was deprecated since macOS 10.15.4
// but now (macOS 11.2.2) it seems to be deprecated for never (100_000)).
return FileHandle.standardInput.readDataToEndOfFile()
// return try input.data(using: .utf8)
// .unwrapOrThrow(error: .invalidData("Unable to get data from standard input"))
// if #available(OSX 10.15.4, *) {
// do {
// return try FileHandle
// .standardInput
// .readToEnd()
// .unwrapOrThrow(error: .invalidData("Unable to get data from standard input"))
// }
// return standardInputData
// } catch {
// throw RuntimeError.invalidData("Error while reading data from standard input. \(error.localizedDescription)")
// }
// } else {
// return FileHandle.standardInput.readDataToEndOfFile()
// }
}
private func makeExplorer<P: SerializablePathExplorer>(_ type: P.Type, from data: Data) throws -> P {
do {
return try P(data: data)
} catch {
throw RuntimeError.unknownFormat("The input cannot be read as \(dataFormat).\n\(error.localizedDescription)")
}
}
func makePathExplorer(for dataFormat: Scout.DataFormat, from data: Data) throws {
switch dataFormat {
case .json:
let json = try makeExplorer(Json.self, from: data)
try inferred(pathExplorer: json)
case .plist:
let plist = try makeExplorer(Plist.self, from: data)
try inferred(pathExplorer: plist)
case .yaml:
let yaml = try makeExplorer(Yaml.self, from: data)
try inferred(pathExplorer: yaml)
case .xml:
let xml = try makeExplorer(Xml.self, from: data)
try inferred(pathExplorer: xml)
}
}
@available(*, deprecated, message: "Useless with the new required dataFormat flag.")
func inferPathExplorer(from data: Data, in inputFilePath: String?) throws {
if let json = try? Json(data: data) {
try inferred(pathExplorer: json)
} else if let plist = try? Plist(data: data) {
try inferred(pathExplorer: plist)
} else if let yaml = try? Yaml(data: data) {
try inferred(pathExplorer: yaml)
} else if let xml = try? Xml(data: data) {
try inferred(pathExplorer: xml)
} else {
if let filePath = inputFilePath {
throw RuntimeError.unknownFormat("The format of the file at \(filePath) is not recognized")
} else {
throw RuntimeError.unknownFormat("The format of the input stream is not recognized")
}
}
}
/// Retrieve the color file to colorise the output if one is found
func getColorFile() throws -> ColorFile? {
let colorFileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".scout/Colors.plist")
guard let data = try? Data(contentsOf: colorFileURL) else { return nil }
return try PropertyListDecoder().decode(ColorFile.self, from: data)
}
func colorInjector(for format: Scout.DataFormat) throws -> TextInjector {
switch format {
case .json:
let jsonInjector = JSONInjector(type: .terminal)
if let colors = try getColorFile()?.json {
jsonInjector.delegate = JSONInjectorColorDelegate(colors: colors)
}
return jsonInjector
case .plist:
let plistInjector = PlistInjector(type: .terminal)
if let colors = try getColorFile()?.plist {
plistInjector.delegate = PlistInjectorColorDelegate(colors: colors)
}
return plistInjector
case .yaml:
let yamlInjector = YAMLInjector(type: .terminal)
if let colors = try getColorFile()?.yaml {
yamlInjector.delegate = YAMLInjectorColorDelegate(colors: colors)
}
return yamlInjector
case .xml:
let xmlInjector = XMLEnhancedInjector(type: .terminal)
if let colors = try getColorFile()?.xml {
xmlInjector.delegate = XMLInjectorColorDelegate(colors: colors)
}
return xmlInjector
}
}
/// Try to get the regex from the pattern, throwing a `RuntimeError` when failing
func regexFrom(pattern: String) throws -> NSRegularExpression {
guard let regex = try? NSRegularExpression(pattern: pattern) else {
throw RuntimeError.invalidRegex(pattern)
}
return regex
}
}

View File

@ -16,8 +16,8 @@ private let discussion =
To find advanced help and rich examples, please type `scout doc`.
Written by Alexis Bridoux.
\u{001B}[38;5;88mhttps://github.com/ABridoux/scout\u{001B}[0;0m
Written by Alexis Bridoux. Copyright (c) 2020-present.
\u{001B}[38;5;88mhttps://www.woodys-findings.com/scout\u{001B}[0;0m
MIT license, see LICENSE file for details
"""
@ -37,7 +37,7 @@ struct ScoutMainCommand: ParsableCommand {
AddCommand.self,
DocCommand.self,
PathsCommand.self,
CSVCommand.self,
InstallCompletionScriptCommand.self],
defaultSubcommand: ReadCommand.self)
}

View File

@ -7,22 +7,26 @@ import Foundation
enum RuntimeError: LocalizedError {
case invalidData(String)
case dataToString
case noValueAt(path: String)
case unknownFormat(String)
case completionScriptInstallation(description: String)
case invalidRegex(String)
case invalidArgumentsCombination(description: String)
case valueConversion(value: String, type: String)
case custom(String)
var errorDescription: String? {
switch self {
case .invalidData(let description): return description
case .dataToString: return "The input data cannot be converted to UTF-8 String"
case .noValueAt(let path): return "No value at '\(path)'"
case .unknownFormat(let description): return description
case .completionScriptInstallation(let description): return "Error while installing the completion script. \(description)"
case .invalidRegex(let pattern): return "The regular expression '\(pattern)' is invalid"
case .invalidArgumentsCombination(let description): return description
case .valueConversion(let value, let type): return "The value \(value) is not convertible to \(type)"
case .custom(let description): return description
}
}
}

View File

@ -10,7 +10,7 @@ import Lux
import ScoutCLTCore
/// A Set/Add/Delete command for default implementations
protocol SADCommand: ScoutCommand, ExportCommand {
protocol SADCommand: PathExplorerInputCommand, ExportCommand {
associatedtype PathCollection: Collection

View File

@ -9,7 +9,7 @@ import ArgumentParser
extension PathsFilter.ValueTarget: EnumerableFlag {}
struct PathsCommand: ScoutCommand {
struct PathsCommand: PathExplorerInputCommand {
// MARK: - Constants
@ -20,7 +20,7 @@ struct PathsCommand: ScoutCommand {
// MARK: - Properties
@Option(name: DataFormat.name)
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: Scout.DataFormat
@Argument(help: "Initial path from which the paths should be listed")

View File

@ -9,7 +9,7 @@ import Foundation
import Lux
import ScoutCLTCore
struct ReadCommand: ScoutCommand, ExportCommand {
struct ReadCommand: PathExplorerInputCommand, ExportCommand {
// MARK: - Constants
@ -30,7 +30,7 @@ struct ReadCommand: ScoutCommand, ExportCommand {
&& !FileHandle.standardOutput.isPiped
}
@Option(name: DataFormat.name)
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: Scout.DataFormat
@Argument(help: .readingPath)

View File

@ -17,7 +17,7 @@ struct SetCommand: SADCommand {
// MARK: - Properties
@Option(name: DataFormat.name)
@Option(name: .dataFormat, help: .dataFormat)
var dataFormat: Scout.DataFormat
@Argument(help: PathAndValue.help)