From 7c6e4fc40e4fb7b522eb1e84b6fa11dfc4f7b861 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Wed, 22 Nov 2023 10:46:43 +0100 Subject: [PATCH 1/2] Feedback. --- src/ui.toit | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/ui.toit b/src/ui.toit index 2dc34a4..4fe3178 100644 --- a/src/ui.toit +++ b/src/ui.toit @@ -5,7 +5,7 @@ import encoding.json interface Printer: - emit o/any --title/string?=null --header/Map?=null + emit object/any --title/string?=null --header/Map?=null emit-structured [--json] [--stdout] abstract class PrinterBase implements Printer: @@ -14,11 +14,11 @@ abstract class PrinterBase implements Printer: abstract needs-structured_ -> bool abstract print_ str/string - abstract handle-structured_ o/any + abstract handle-structured_ object/any - emit o/any --title/string?=null --header/Map?=null: + emit object/any --title/string?=null --header/Map?=null: if needs-structured_: - handle-structured_ o + handle-structured_ object return // Prints the prefix on a line. Typically something like 'Warning: ' or 'Error: '. @@ -35,21 +35,21 @@ abstract class PrinterBase implements Printer: print_ "$title:" indentation = " " - if o is List and header: + if object is List and header: // A table. print-prefix-on-line.call - emit-table_ --title=title --header=header (o as List) - else if o is List: + emit-table_ --title=title --header=header (object as List) + else if object is List: print-prefix-on-line.call print-title-on-line.call - emit-list_ (o as List) --indentation=indentation - else if o is Map: + emit-list_ (object as List) --indentation=indentation + else if object is Map: print-prefix-on-line.call print-title-on-line.call - emit-map_ (o as Map) --indentation=indentation + emit-map_ (object as Map) --indentation=indentation else: // Convert to string. - msg := "$o" + msg := "$object" if title: msg = "$title: $msg" if prefix_: @@ -92,10 +92,10 @@ abstract class PrinterBase implements Printer: entry/string := "$row[key]" column-sizes.update key: | old/int | max old (entry.size --runes) - pad := : | o/Map | + pad := : | object/Map | padded-row := [] column-sizes.do: | key size | - entry := "$o[key]" + entry := "$object[key]" // TODO(florian): allow alignment. padded := entry + " " * (size - (entry.size --runes)) padded-row.add padded @@ -162,23 +162,23 @@ abstract class Ui: generator.call (printer_ --kind=kind) /** Reports an error. */ - error o/any: - do --kind=ERROR: | printer/Printer | printer.emit o + error object/any: + do --kind=ERROR: | printer/Printer | printer.emit object /** Reports a warning. */ - warning o/any: - do --kind=WARNING: | printer/Printer | printer.emit o + warning object/any: + do --kind=WARNING: | printer/Printer | printer.emit object - info o/any: - do --kind=INFO: | printer/Printer | printer.emit o + info object/any: + do --kind=INFO: | printer/Printer | printer.emit object - print o/any: info o + print object/any: info object - result o/any: - do --kind=RESULT: | printer/Printer | printer.emit o + result object/any: + do --kind=RESULT: | printer/Printer | printer.emit object - abort o/any: - do --kind=ERROR: | printer/Printer | printer.emit o + abort object/any: + do --kind=ERROR: | printer/Printer | printer.emit object abort printer_ --kind/int -> Printer: From ab9d84edf77c8fc9d0f2632c4677d0ac85d330cc Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Wed, 22 Nov 2023 10:48:59 +0100 Subject: [PATCH 2/2] Port UI. (#40) --- src/cli.toit | 19 +-- src/help-generator_.toit | 9 +- src/ui.toit | 259 +++++++++++++++++++++++++++++++++++++ tests/test_ui.toit | 25 +++- tests/ui-test.toit | 270 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 555 insertions(+), 27 deletions(-) create mode 100644 src/ui.toit create mode 100644 tests/ui-test.toit diff --git a/src/cli.toit b/src/cli.toit index 936082e..a449956 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -7,16 +7,9 @@ import uuid import .parser_ import .utils_ import .help-generator_ +import .ui -/** -When the arg-parser needs to report an error, or write a help message, it - uses this interface. - -The $abort function either calls $exit or throws an exception. -*/ -interface Ui: - print str/string - abort -> none +export Ui /** A command. @@ -179,7 +172,7 @@ class Command: The default $ui prints to stdout and calls `exit 1` when $Ui.abort is called. */ - run arguments/List --invoked-command=program-name --ui/Ui=Ui_ -> none: + run arguments/List --invoked-command=program-name --ui/Ui=ConsoleUi -> none: parser := Parser_ --ui=ui --invoked-command=invoked-command parsed := parser.parse this arguments parsed.command.run-callback_.call parsed @@ -841,9 +834,3 @@ class Parsed: buffer := [] options_.do: | name value | buffer.add "$name=$value" return buffer.join " " - -global-print_ str/string: print str - -class Ui_ implements Ui: - print str/string: global-print_ str - abort: exit 1 diff --git a/src/help-generator_.toit b/src/help-generator_.toit index e639feb..053fce9 100644 --- a/src/help-generator_.toit +++ b/src/help-generator_.toit @@ -5,6 +5,7 @@ import .cli import .parser_ import .utils_ +import .ui /** The 'help' command that can be executed on the root command. @@ -610,16 +611,10 @@ class HelpGenerator: return buffer_.join "" -global-print_ str/string: - print str - -class ExampleUi_ implements Ui: +class ExampleUi_ extends ConsoleUi: example_/string constructor .example_: - print str/string: - global-print_ str - abort: throw "Error in example: $example_" diff --git a/src/ui.toit b/src/ui.toit new file mode 100644 index 0000000..4fe3178 --- /dev/null +++ b/src/ui.toit @@ -0,0 +1,259 @@ +// Copyright (C) 2023 Toitware ApS. All rights reserved. +// Use of this source code is governed by an MIT-style license that can be +// found in the package's LICENSE file. + +import encoding.json + +interface Printer: + emit object/any --title/string?=null --header/Map?=null + emit-structured [--json] [--stdout] + +abstract class PrinterBase implements Printer: + prefix_/string? := ? + constructor .prefix_: + + abstract needs-structured_ -> bool + abstract print_ str/string + abstract handle-structured_ object/any + + emit object/any --title/string?=null --header/Map?=null: + if needs-structured_: + handle-structured_ object + return + + // Prints the prefix on a line. Typically something like 'Warning: ' or 'Error: '. + print-prefix-on-line := : + if prefix_: + print_ prefix_ + prefix_ = null + + indentation := "" + // Local block that prints the title, if any on one line, and + // adjusts the indentation. + print-title-on-line := : + if title: + print_ "$title:" + indentation = " " + + if object is List and header: + // A table. + print-prefix-on-line.call + emit-table_ --title=title --header=header (object as List) + else if object is List: + print-prefix-on-line.call + print-title-on-line.call + emit-list_ (object as List) --indentation=indentation + else if object is Map: + print-prefix-on-line.call + print-title-on-line.call + emit-map_ (object as Map) --indentation=indentation + else: + // Convert to string. + msg := "$object" + if title: + msg = "$title: $msg" + if prefix_: + msg = "$prefix_$msg" + prefix_ = null + print_ msg + + emit-list_ list/List --indentation/string: + list.do: + // TODO(florian): should the entries be recursively pretty-printed? + print_ "$indentation$it" + + emit-map_ map/Map --indentation/string: + map.do: | key value | + if value is Map: + print_ "$indentation$key:" + emit-map_ value --indentation="$indentation " + else: + // TODO(florian): should the entries handle lists as well. + print_ "$indentation$key: $value" + + emit-table_ --title/string?=null --header/Map table/List: + if needs-structured_: + handle-structured_ table + return + + if prefix_: + print_ prefix_ + prefix_ = null + + // TODO(florian): make this look nicer. + if title: + print_ "$title:" + + column-count := header.size + column-sizes := header.map: | _ header-string/string | header-string.size --runes + + table.do: | row/Map | + header.do --keys: | key/string | + entry/string := "$row[key]" + column-sizes.update key: | old/int | max old (entry.size --runes) + + pad := : | object/Map | + padded-row := [] + column-sizes.do: | key size | + entry := "$object[key]" + // TODO(florian): allow alignment. + padded := entry + " " * (size - (entry.size --runes)) + padded-row.add padded + padded-row + + bars := column-sizes.values.map: "─" * it + print_ "┌─$(bars.join "─┬─")─┐" + + sized-header-entries := [] + padded-row := pad.call header + print_ "│ $(padded-row.join " ") │" + print_ "├─$(bars.join "─┼─")─┤" + + table.do: | row | + padded-row = pad.call row + print_ "│ $(padded-row.join " ") │" + print_ "└─$(bars.join "─┴─")─┘" + + emit-structured [--json] [--stdout]: + if needs-structured_: + handle-structured_ json.call + return + + stdout.call this + +/** +A class for handling input/output from the user. + +The Ui class is used to display text to the user and to get input from the user. +*/ +abstract class Ui: + static DEBUG ::= 0 + static VERBOSE ::= 1 + static INFO ::= 2 + static WARNING ::= 3 + static INTERACTIVE ::= 4 + static ERROR ::= 5 + static RESULT ::= 6 + + static DEBUG-LEVEL ::= -1 + static VERBOSE-LEVEL ::= -2 + static NORMAL-LEVEL ::= -3 + static QUIET-LEVEL ::= -4 + static SILENT-LEVEL ::= -5 + + level/int + constructor --.level/int: + if not DEBUG-LEVEL >= level >= SILENT-LEVEL: + error "Invalid level: $level" + + do --kind/int=Ui.INFO [generator] -> none: + if level == DEBUG-LEVEL: + // Always triggers. + else if level == VERBOSE-LEVEL: + if kind < VERBOSE: return + else if level == NORMAL-LEVEL: + if kind < INFO: return + else if level == QUIET-LEVEL: + if kind < INTERACTIVE: return + else if level == SILENT-LEVEL: + if kind < RESULT: return + else: + error "Invalid level: $level" + generator.call (printer_ --kind=kind) + + /** Reports an error. */ + error object/any: + do --kind=ERROR: | printer/Printer | printer.emit object + + /** Reports a warning. */ + warning object/any: + do --kind=WARNING: | printer/Printer | printer.emit object + + info object/any: + do --kind=INFO: | printer/Printer | printer.emit object + + print object/any: info object + + result object/any: + do --kind=RESULT: | printer/Printer | printer.emit object + + abort object/any: + do --kind=ERROR: | printer/Printer | printer.emit object + abort + + printer_ --kind/int -> Printer: + prefix/string? := null + if kind == Ui.WARNING: + prefix = "Warning: " + else if kind == Ui.ERROR: + prefix = "Error: " + return create-printer_ prefix kind + + /** + Aborts the program with the given error message. + + # Inheritance + It is safe to override this method with a custom implementation. The + method should always abort. Either with 'exit 1', or with an exception. + */ + abort -> none: + exit 1 + + /** + Creates a new printer for the given $kind. + + # Inheritance + Customization generally happens at this level, by providing different + implementations of the $Printer class. + */ + abstract create-printer_ prefix/string? kind/int -> Printer + +/** +Prints the given $str using $print. + +This function is necessary, as $ConsolePrinter has its own 'print' method, + which shadows the global one. +*/ +global-print_ str/string: + print str + +class ConsolePrinter extends PrinterBase: + constructor prefix/string?: + super prefix + + needs-structured_: return false + + print_ str/string: + global-print_ str + + handle-structured_ structured: + unreachable + +class ConsoleUi extends Ui: + + constructor --level/int=Ui.NORMAL-LEVEL: + super --level=level + + create-printer_ prefix/string? kind/int -> Printer: + return ConsolePrinter prefix + +class JsonPrinter extends PrinterBase: + kind_/int + + constructor prefix/string? .kind_: + super prefix + + needs-structured_: return kind_ == Ui.RESULT + + print_ str/string: + print-on-stderr_ str + + handle-structured_ structured: + global-print_ (json.stringify structured) + +class JsonUi extends Ui: + constructor --level/int=Ui.QUIET-LEVEL: + super --level=level + + create-printer_ prefix/string? kind/int -> Printer: + return JsonPrinter prefix kind diff --git a/tests/test_ui.toit b/tests/test_ui.toit index 8fa2218..f6bc084 100644 --- a/tests/test_ui.toit +++ b/tests/test_ui.toit @@ -2,18 +2,35 @@ // Use of this source code is governed by a Zero-Clause BSD license that can // be found in the tests/LICENSE file. -import cli +import cli.ui as cli import expect show * -class TestUi implements cli.Ui: +class TestUi extends cli.Ui: messages := [] - print str/string: - messages.add str + constructor --level/int=cli.Ui.NORMAL-LEVEL: + super --level=level + + create-printer_ prefix/string? kind/int -> cli.Printer: + return TestPrinter this prefix abort: throw "abort" +class TestPrinter extends cli.PrinterBase: + ui_/TestUi + + constructor .ui_ prefix/string?: + super prefix + + needs-structured_: return false + + print_ str/string: + ui_.messages.add str + + handle-structured_ structured: + unreachable + expect-abort expected/string [block]: ui := TestUi exception := catch: diff --git a/tests/ui-test.toit b/tests/ui-test.toit new file mode 100644 index 0000000..1ea8482 --- /dev/null +++ b/tests/ui-test.toit @@ -0,0 +1,270 @@ +// Copyright (C) 2023 Toitware ApS. +// Use of this source code is governed by a Zero-Clause BSD license that can +// be found in the tests/LICENSE file. + +import cli.ui show * +import encoding.json +import expect show * + +class TestPrinter extends PrinterBase: + test-ui_/TestUi + needs-structured_/bool + + constructor .test-ui_ prefix/string? --needs-structured/bool: + needs-structured_ = needs-structured + super prefix + + print_ str/string: + test-ui_.stdout += "$str\n" + + handle-structured_ o: + test-ui_.structured.add o + +class TestUi extends ConsoleUi: + stdout := "" + structured := [] + + needs-structured/bool + + constructor --level/int=Ui.NORMAL-LEVEL --.needs-structured/bool: + super --level=level + + create-printer_ prefix/string? _ -> TestPrinter: + return TestPrinter this prefix --needs-structured=needs-structured + + reset: + stdout = "" + structured = [] + +class TestJsonPrinter extends JsonPrinter: + test-ui_/TestJsonUi + + constructor .test-ui_ prefix/string? kind/int: + super prefix kind + + print_ str/string: + test-ui_.stderr += "$str\n" + + handle-structured_ structured: + test-ui_.stdout += (json.stringify structured) + +class TestJsonUi extends JsonUi: + stdout := "" + stderr := "" + + constructor --level/int=Ui.NORMAL-LEVEL: + super --level=level + + create-printer_ prefix/string? kind/int -> TestJsonPrinter: + return TestJsonPrinter this prefix kind + + reset: + stdout = "" + stderr = "" + +main: + test-console + test-structured + test-json + +test-console: + ui := TestUi --no-needs-structured + + ui.info "hello" + expect-equals "hello\n" ui.stdout + ui.reset + + ui.info ["hello", "world"] + expect-equals "hello\nworld\n" ui.stdout + ui.reset + + ui.do: | printer/Printer | + printer.emit --title="French" ["bonjour", "monde"] + expect-equals "French:\n bonjour\n monde\n" ui.stdout + ui.reset + + ui.do: | printer/Printer | + printer.emit + --header={"x": "x", "y": "y"} + [ + { "x": "a", "y": "b" }, + { "x": "c", "y": "d" }, + ] + expect-equals """ + ┌───┬───┐ + │ x y │ + ├───┼───┤ + │ a b │ + │ c d │ + └───┴───┘ + """ ui.stdout + ui.reset + + ui.do: | printer/Printer | + printer.emit + --header={ "left": "long", "right": "even longer" } + [ + { "left": "a", "right": "short" }, + { "left": "longer", "right": "d" }, + ] + expect-equals """ + ┌────────┬─────────────┐ + │ long even longer │ + ├────────┼─────────────┤ + │ a short │ + │ longer d │ + └────────┴─────────────┘ + """ ui.stdout + ui.reset + + ui.do: | printer/Printer | + printer.emit + --header={"left": "no", "right": "rows"} + [] + expect-equals """ + ┌────┬──────┐ + │ no rows │ + ├────┼──────┤ + └────┴──────┘ + """ ui.stdout + ui.reset + + ui.do: | printer/Printer | + printer.emit + --header={"left": "with", "right": "ints"} + [ + { + "left": 1, + "right": 2, + }, + { + "left": 3, + "right": 4, + }, + ] + expect-equals """ + ┌──────┬──────┐ + │ with ints │ + ├──────┼──────┤ + │ 1 2 │ + │ 3 4 │ + └──────┴──────┘ + """ ui.stdout + ui.reset + + ui.info { + "a": "b", + "c": "d", + } + expect-equals """ + a: b + c: d + """ ui.stdout + ui.reset + + // Nested maps. + ui.info { + "a": { + "b": "c", + "d": "e", + }, + "f": "g", + } + expect-equals """ + a: + b: c + d: e + f: g + """ ui.stdout + ui.reset + + ui.print "foo" + expect-equals "foo\n" ui.stdout + ui.reset + + ui.warning "foo" + expect-equals "Warning: foo\n" ui.stdout + ui.reset + + ui.error "foo" + expect-equals "Error: foo\n" ui.stdout + ui.reset + + ui.print { + "entry with int": 499, + } + expect-equals """ + entry with int: 499 + """ ui.stdout + ui.reset + +test-structured: + ui := TestUi --needs-structured + + ui.info "hello" + expect-equals ["hello"] ui.structured + ui.reset + + map := { + "foo": 1, + "bar": 2, + } + ui.info map + expect-equals 1 ui.structured.size + expect-identical map ui.structured[0] + ui.reset + + list := [ + "foo", + "bar", + ] + ui.info list + expect-equals 1 ui.structured.size + expect-identical list ui.structured[0] + ui.reset + + ui.do: | printer/Printer | + printer.emit --title="French" ["bonjour", "monde"] + expect-equals 1 ui.structured.size + expect-equals ["bonjour", "monde"] ui.structured[0] + ui.reset + + data := [ + { "x": "a", "y": "b" }, + { "x": "c", "y": "d" }, + ] + ui.do: | printer/Printer | + printer.emit + --header={"x": "x", "y": "y"} + data + expect-equals 1 ui.structured.size + expect-structural-equals data ui.structured[0] + ui.reset + +test-json: + ui := TestJsonUi + + // Anything that isn't a result is emitted on stderr as if it was + // a console Ui. + ui.info "hello" + expect-equals "hello\n" ui.stderr + ui.reset + + // Results are emitted on stdout as JSON. + ui.result "hello" + expect-equals "\"hello\"" ui.stdout + ui.reset + + ui.result { + "foo": 1, + "bar": 2, + } + expect-equals "{\"foo\":1,\"bar\":2}" ui.stdout + + ui.warning "some warning" + expect-equals "Warning: some warning\n" ui.stderr + ui.reset + + ui.error "some error" + expect-equals "Error: some error\n" ui.stderr + ui.reset