diff --git a/Sources/table/Format.swift b/Sources/table/Format.swift index 3230073..5c68917 100644 --- a/Sources/table/Format.swift +++ b/Sources/table/Format.swift @@ -1,7 +1,7 @@ import Foundation // Structure representing a format tree -protocol FormatExpr: CustomStringConvertible, Equatable { +protocol FormatExpr: CustomStringConvertible { func fill(row: Row) throws -> String func validate(header: Header?) throws -> Void } @@ -26,7 +26,7 @@ struct VarPart: FormatExpr { } var description: String { - return "Var(name: \(name))" + return "Var(\(name))" } } @@ -50,11 +50,14 @@ struct TextPart: FormatExpr { struct FunctionPart: FormatExpr { let name: String + let arguments: [any FormatExpr] - static let internalFunctions = ["header", "values", "quoted_values", "uuid"] + static let internalFunctions = ["header", "values", "quoted_values", "uuid", "random", "randomChoice"] + static let regex: NSRegularExpression = try! NSRegularExpression(pattern: "\\$\\{([%A-Za-z0-9_\\s]+)\\}") - init(fnName: String) { - self.name = fnName + init(name: String, arguments: [any FormatExpr] = []) { + self.name = name + self.arguments = arguments } func fill(row: Row) throws -> String { @@ -73,6 +76,20 @@ struct FunctionPart: FormatExpr { return UUID().uuidString } + if name == "random" { + let from = arguments.count == 1 ? try Int(arguments[0].fill(row: row))! : 0 + let to = arguments.count == 2 ? try Int(arguments[1].fill(row: row))! : try Int(arguments[0].fill(row: row))! + return String(Int.random(in: from...to)) + } + + if name == "randomChoice" { + if arguments.isEmpty { + throw RuntimeError("randomChoice function requires at least one argument") + } + let choices = try arguments.map { try $0.fill(row: row) } + return choices.randomElement() ?? "" + } + if name == "quoted_values" { return row.components.enumerated().map { (index, cell) in let v = cell.value @@ -92,13 +109,27 @@ struct FunctionPart: FormatExpr { func validate(header: Header?) throws { if let h = header { if !FunctionPart.internalFunctions.contains(name) && h.index(ofColumn: name) == nil { - throw RuntimeError("Unknown function in format: \(name). Supported columns: \(FunctionPart.internalFunctions.joined(separator: ", "))") + throw RuntimeError("Unknown function in format: \(name). Supported functions: \(FunctionPart.internalFunctions.joined(separator: ", "))") + } + + if (name == "random") { + if arguments.count < 0 { + throw RuntimeError("Function \(name) accepts one or two arguments. It should be either random(to) or random(from, to)") + } + + if arguments.count > 2 { + throw RuntimeError("Function \(name) accepts at most two arguments, got \(arguments.count). It should be either random(to) or random(from, to)") + } + } + + if (name == "randomChoice") { + if arguments.isEmpty { throw RuntimeError("Function \(name) requires at least one argument") } } } } var description: String { - return "Function(name: \(name))" + return "Fun(name: \(name), arguments: \(arguments))" } } @@ -124,7 +155,7 @@ struct FormatGroup: FormatExpr { } var description: String { - return "Group(parts: \(parts))" + return "Group(\(parts))" } static func == (lhs: FormatGroup, rhs: FormatGroup) -> Bool { @@ -157,14 +188,14 @@ struct ExecPart: FormatExpr { } class Format { - static let regex: NSRegularExpression = try! NSRegularExpression(pattern: "\\$\\{([%A-Za-z0-9_\\s]+)\\}") - let original: String let format: any FormatExpr init(format: String) { self.original = format - self.format = Format.parse(original).0 + let (nodes, _) = Format.parse(original) + + self.format = FormatGroup(nodes) } func validated(header: Header?) throws -> Format { @@ -181,55 +212,44 @@ class Format { fill(row: row).data(using: .utf8)! } - static func parse(_ input: String, from start: String.Index? = nil, until closing: Character? = nil) -> (any FormatExpr, String.Index) { + static func parse(_ input: String, from start: String.Index? = nil, until terminators: Set = []) -> ([any FormatExpr], String.Index) { var index = start ?? input.startIndex var nodes: [any FormatExpr] = [] var buffer = "" while index < input.endIndex { - // Handle closing delimiter if needed - if let closing = closing, input[index] == closing { - if !buffer.isEmpty { - nodes.append(TextPart(buffer)) - } - return (FormatGroup(nodes), input.index(after: index)) + let char = input[index] + + if terminators.contains(char) { + if !buffer.isEmpty { nodes.append(TextPart(buffer)); buffer = "" } + return (nodes, input.index(after: index)) } if input[index...].hasPrefix("${") { - if !buffer.isEmpty { - nodes.append(TextPart(buffer)) - buffer = "" - } + if !buffer.isEmpty { nodes.append(TextPart(buffer)); buffer = "" } index = input.index(index, offsetBy: 2) - let (name, newIndex) = readUntil(input, delimiter: "}", from: index) + let (name, newIndex) = readUntil(input, from: index, delimiter: "}") if let name = name { nodes.append(VarPart(name)) } index = newIndex } else if input[index...].hasPrefix("%{") { - if !buffer.isEmpty { - nodes.append(TextPart(buffer)) - buffer = "" - } + if !buffer.isEmpty { nodes.append(TextPart(buffer)); buffer = "" } index = input.index(index, offsetBy: 2) - let (name, newIndex) = readUntil(input, delimiter: "}", from: index) - if let name = name { - nodes.append(FunctionPart(fnName: name)) - } + let (funcNode, newIndex) = parseFunction(input, from: index) + nodes.append(funcNode) index = newIndex + } else if input[index...].hasPrefix("#{") { - if !buffer.isEmpty { - nodes.append(TextPart(buffer)) - buffer = "" - } + if !buffer.isEmpty { nodes.append(TextPart(buffer)); buffer = "" } index = input.index(index, offsetBy: 2) - let (inner, newIndex) = parse(input, from: index, until: "}") - nodes.append(ExecPart(command: inner)) + let (inner, newIndex) = parse(input, from: index, until: ["}"]) + nodes.append(ExecPart(command: FormatGroup(inner))) index = newIndex } else { - buffer.append(input[index]) + buffer.append(char) index = input.index(after: index) } } @@ -238,10 +258,54 @@ class Format { nodes.append(TextPart(buffer)) } - return (FormatGroup(nodes), index) + return (nodes, index) } - private static func readUntil(_ input: String, delimiter: Character, from start: String.Index) -> (String?, String.Index) { + private static func parseFunction(_ input: String, from start: String.Index) -> (any FormatExpr, String.Index) { + var index = start + var name = "" + + while index < input.endIndex, input[index].isLetter || input[index].isNumber || input[index] == "_" { + name.append(input[index]) + index = input.index(after: index) + } + + skipWhitespace(input, &index) + + var args: [any FormatExpr] = [] + + if index < input.endIndex, input[index] == "(" { + index = input.index(after: index) + while index < input.endIndex && input[index] != "}" { + skipWhitespace(input, &index) + + if input[index] == ")" { + index = input.index(after: index) + break + } + + let (argNodes, newIndex) = parse(input, from: index, until: [",", ")"]) + if argNodes.count == 1 { + args.append(argNodes[0]) + } else { + args.append(FormatGroup(argNodes)) + } + + index = newIndex + if index < input.endIndex, input[index] == "," { + index = input.index(after: index) + } + } + } + + guard index < input.endIndex, input[index] == "}" else { + fatalError("Expected closing } for function") + } + + return (FunctionPart(name: name, arguments: args), input.index(after: index)) + } + + private static func readUntil(_ input: String, from start: String.Index, delimiter: Character) -> (String?, String.Index) { var index = start var result = "" @@ -255,4 +319,10 @@ class Format { return (nil, index) } + + private static func skipWhitespace(_ input: String, _ index: inout String.Index) { + while index < input.endIndex, input[index].isWhitespace { + index = input.index(after: index) + } + } } \ No newline at end of file diff --git a/Sources/table/Join.swift b/Sources/table/Join.swift index 255b591..1435c1d 100644 --- a/Sources/table/Join.swift +++ b/Sources/table/Join.swift @@ -15,9 +15,7 @@ class Join { self.matchTable = matchTable self.secondColIndex = try matchTable.header.index(ofColumn: secondColumn).orThrow(RuntimeError("Column \(secondColumn) is not found in table")) - if (Global.debug) { - print("Joining table on columns \(firstColumn)=\(secondColumn)") - } + debug("Joining table on columns \(firstColumn)=\(secondColumn)") } func matching(row: Row) -> Row? { diff --git a/Sources/table/LineReader.swift b/Sources/table/LineReader.swift index ca227d6..fdd8a82 100644 --- a/Sources/table/LineReader.swift +++ b/Sources/table/LineReader.swift @@ -72,5 +72,25 @@ class ArrayLineReader: LineReader { } } + func close() {} +} + +class GeneratedLineReader: LineReader { + private var index = 0 + let total: Int + + init(lines: Int) { + self.total = lines + } + + func readLine() -> String? { + if index == total { + return nil + } else { + index += 1 + return "" + } + } + func close() {} } \ No newline at end of file diff --git a/Sources/table/MainApp.swift b/Sources/table/MainApp.swift index 8c4cc5c..4655411 100644 --- a/Sources/table/MainApp.swift +++ b/Sources/table/MainApp.swift @@ -32,7 +32,7 @@ func buildPrinter(formatOpt: Format?, outFileFmt: FileType, outputFile: String?) @main struct MainApp: ParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "table", abstract: "A utility for transforming CSV files of SQL output.", discussion: """ @@ -61,8 +61,8 @@ struct MainApp: ParsableCommand { @Flag(name: .customLong("no-out-header"), help: "Do not print header in the output.") var skipOutHeader = false - @Flag(help: "Prints debug output") - var debug = false + @Flag(name: .customLong("debug"), help: "Prints debug output") + var debugEnabled = false @Option(name: .customLong("header"), help: "Override header. Columns should be specified separated by comma.") var header: String? @@ -85,9 +85,8 @@ struct MainApp: ParsableCommand { @Option(name: [.customLong("as")], help: "Prints output in the specified format. Supported formats: table (default) or csv.") var asFormat: String? - // TODO: Support complex or multiple filters? - @Option(name: .shortAndLong, help: "Filter rows by a single value criteria. Example: country=UA or size>10. Supported comparison operations: '=' - equal,'!=' - not equal, < - smaller, <= - smaller or equal, > - bigger, >= - bigger or equal, '^=' - starts with, '$=' - ends with, '~=' - contains.") - var filter: String? + @Option(name: [.customShort("f"), .customLong("filter")], help: "Filter rows by a single value criteria. Example: country=UA or size>10. Supported comparison operations: '=' - equal,'!=' - not equal, < - smaller, <= - smaller or equal, > - bigger, >= - bigger or equal, '^=' - starts with, '$=' - ends with, '~=' - contains.") + var filters: [String] = [] @Option(name: .customLong("add"), help: "Adds a new column from a shell command output allowing to substitute other column values into it. Expressions ${name} and #{cmd} are substituted by column value and command result respectively. Example: --add 'col_name=#{curl http://email-db.com/${email}}'.") var addColumns: [String] = [] @@ -107,9 +106,12 @@ struct MainApp: ParsableCommand { @Option(name: .customLong("sample"), help: "Samples percentage of the total rows. Example: --sample 50. Samples only half of the rows.") var sample: Int? + @Option(name: .customLong("generate"), help: "Generates a sample empty table with the specified number of rows. Example: '--generate 1000 --add id=%{uuid}' will generate a table of UUIDs with 1000 rows.") + var generate: Int? + mutating func run() throws { - if debug { + if debugEnabled { Global.debug = true print("Debug enabled") } @@ -118,9 +120,19 @@ struct MainApp: ParsableCommand { let headerOverride = header.map { try! Header(data: $0, delimeter: ",", trim: false, hasOuterBorders: false) } - var table: any Table = try ParsedTable.parse(path: inputFile, hasHeader: !noInHeader, headerOverride: headerOverride, delimeter: delimeter, userTypes: userTypes) + var table: any Table + + if let generate { + if inputFile != nil { + throw RuntimeError("Input file is not expected when generating rows. Use --generate without input file.") + } + debug("Generating \(generate) rows") + table = ParsedTable.generated(rows: generate) + } else { + table = try ParsedTable.parse(path: inputFile, hasHeader: !noInHeader, headerOverride: headerOverride, delimeter: delimeter, userTypes: userTypes) + } - let filter = try filter.map { try Filter.compile(filter: $0, header: table.header) } + let parsedFilters = filters.isEmpty ? nil : try filters.map { try Filter.compile(filter: $0, header: table.header) } if !addColumns.isEmpty { // TODO: add support of Dynamic Row values and move validation right before rendering @@ -133,9 +145,8 @@ struct MainApp: ParsableCommand { let colName = String(parts[0]).trimmingCharacters(in: CharacterSet.whitespaces) let formatStr = String(parts[1]) - if (Global.debug) { - print("Adding a column: \(colName) with format: '\(formatStr)'") - } + debug("Adding a column: \(colName) with format: '\(formatStr)'") + return (colName, try Format(format: formatStr).validated(header: table.header)) } @@ -154,22 +165,20 @@ struct MainApp: ParsableCommand { let formatOpt = try printFormat.map { try Format(format: $0).validated(header: table.header) } if let sortColumns { - let expression = try Sort(sortColumns).validated(header: table.header) - if (Global.debug) { - print("Sorting by columns: \(expression.columns.map { (name, order) in "\(name) \(order)" }.joined(separator: ","))") - } + let expression = try Sort(sortColumns).validated(header: table.header) + debug("Sorting by columns: \(expression.columns.map { (name, order) in "\(name) \(order)" }.joined(separator: ","))") table = try InMemoryTableView(table: table).sort(expr: expression) } if let columns { let columns = columns.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - if (Global.debug) { print("Showing columns: \(columns.joined(separator: ","))") } + debug("Showing columns: \(columns.joined(separator: ","))") try columns.forEach { if table.header.index(ofColumn: $0) == nil { throw RuntimeError("Column \($0) in columns clause is not found in the table") } } table = ColumnsTableView(table: table, visibleColumns: columns) } if let sample { - if (Global.debug) { print("Sampling \(sample)% of the rows") } + debug("Sampling \(sample)% of the rows") table = SampledTableView(table: table, percentage: sample) } @@ -184,8 +193,10 @@ struct MainApp: ParsableCommand { var limit = limitLines ?? Int.max while let row = table.next() { - if let filter { - if !filter.apply(row: row) { continue } + if let parsedFilters { + if (!parsedFilters.allSatisfy { $0.apply(row: row) }) { + continue + } } if (skip > 0) { diff --git a/Sources/table/Table.swift b/Sources/table/Table.swift index f8e4b2f..f3529d2 100644 --- a/Sources/table/Table.swift +++ b/Sources/table/Table.swift @@ -79,6 +79,10 @@ class ParsedTable: Table { return ParsedTable(reader: ArrayLineReader(lines: []), conf: TableConfig(header: Header.auto(size: 0)), prereadRows: []) } + static func generated(rows: Int) -> ParsedTable { + return ParsedTable(reader: GeneratedLineReader(lines: rows), conf: TableConfig(header: Header.auto(size: 0)), prereadRows: []) + } + static func fromArray(_ data: [[String]], header: [String]? = nil) -> ParsedTable { let types = CellType.infer(rows: data) let parsedHeader = header.map { Header(components: $0, types: types) } ?? Header.auto(size: data.count) @@ -91,6 +95,11 @@ class ParsedTable: Table { if let path { file = try FileHandle(forReadingAtPath: path).orThrow(RuntimeError("File \(path) is not found")) } else { + + if (isatty(fileno(stdin)) != 0) { + throw RuntimeError("No input file provided and standard input is not a terminal. Use --input to specify a file or --generate to generate rows.") + } + file = FileHandle.standardInput } diff --git a/Sources/table/TablePrinter.swift b/Sources/table/TablePrinter.swift index 3ee6c2f..276b8e9 100644 --- a/Sources/table/TablePrinter.swift +++ b/Sources/table/TablePrinter.swift @@ -51,6 +51,7 @@ class CustomFormatTablePrinter: TablePrinter { func flush() {} } +// Tool own format that looks nicely in terminal class PrettyTablePrinter: TablePrinter { private let outHandle: FileHandle private var columnWidths: [Int] = [] @@ -106,9 +107,10 @@ class PrettyTablePrinter: TablePrinter { } private func formatRow(_ row: [String]) -> String { + if row.count == 0 { return "││\n" } return row.enumerated().map { (idx, col) in - let padding = String(repeating: " ", count: self.columnWidths[idx] - col.count) - return "│ " + col + padding + " " - }.joined(separator: "") + "│\n" + let padding = String(repeating: " ", count: self.columnWidths[idx] - col.count) + return "│ " + col + padding + " " + }.joined(separator: "") + "│\n" } } \ No newline at end of file diff --git a/Tests/table-Tests/FormatTests.swift b/Tests/table-Tests/FormatTests.swift index fa8217c..377d066 100644 --- a/Tests/table-Tests/FormatTests.swift +++ b/Tests/table-Tests/FormatTests.swift @@ -10,17 +10,18 @@ class FormatTests: XCTestCase { func testParseExpressionsWithVars() throws { let (exprTree, _) = Format.parse("String here: ${str1} and here: ${str2}") - XCTAssertEqual(exprTree.description, "Group(parts: [Text(String here: ), Var(name: str1), Text( and here: ), Var(name: str2)])") + XCTAssertEqual(exprTree.description, "[Text(String here: ), Var(str1), Text( and here: ), Var(str2)]") } func testParseExpressionsWithFns() throws { - let (exprTree, _) = Format.parse("Header: %{header} values: %{values}") - XCTAssertEqual(exprTree.description, "Group(parts: [Text(Header: ), Function(name: header), Text( values: ), Function(name: values)])") + let (exprTree, _) = Format.parse("Header: %{header()}, values: %{values()}, with args: %{fun(1,${str1},%{fun1()})}") // + XCTAssertEqual(exprTree.description, + "[Text(Header: ), Fun(name: header, arguments: []), Text(, values: ), Fun(name: values, arguments: []), Text(, with args: ), Fun(name: fun, arguments: [Text(1), Var(str1), Fun(name: fun1, arguments: [])])]") } func testParseExpressionsWithExec() throws { let (exprTree, _) = Format.parse("Exec: #{echo ${num1} + ${num2}}") - XCTAssertEqual(exprTree.description, "Group(parts: [Text(Exec: ), Exec(command: Group(parts: [Text(echo ), Var(name: num1), Text( + ), Var(name: num2)]))])") + XCTAssertEqual(exprTree.description, "[Text(Exec: ), Exec(command: Group([Text(echo ), Var(num1), Text( + ), Var(num2)]))]") } func testStringFormat() throws {